5 changed files with 917 additions and 2 deletions
@ -0,0 +1,169 @@
@@ -0,0 +1,169 @@
|
||||
<script lang="ts"> |
||||
import RelayInfoDisplay from './RelayInfoDisplay.svelte'; |
||||
import { fetchRelayInfos, type RelayInfoWithMetadata } from '$lib/utils/relay_info_service'; |
||||
|
||||
const { |
||||
relays, |
||||
inboxRelays = [], |
||||
outboxRelays = [], |
||||
showLabels = true, |
||||
compact = false |
||||
} = $props<{ |
||||
relays: string[]; |
||||
inboxRelays?: string[]; |
||||
outboxRelays?: string[]; |
||||
showLabels?: boolean; |
||||
compact?: boolean; |
||||
}>(); |
||||
|
||||
let relayInfos = $state<RelayInfoWithMetadata[]>([]); |
||||
let isLoading = $state(true); |
||||
|
||||
type CategorizedRelay = { |
||||
relay: string; |
||||
category: 'both' | 'inbox' | 'outbox' | 'other'; |
||||
label: string; |
||||
}; |
||||
|
||||
// AI-NOTE: Updated to show only top-3 inboxes and top-3 outboxes as intended |
||||
const categorizedRelays = $derived(() => { |
||||
const inbox = new Set(inboxRelays); |
||||
const outbox = new Set(outboxRelays); |
||||
|
||||
console.log('[RelayInfoList] Categorizing relays:', { |
||||
relays: relays.length, |
||||
inboxRelays: inboxRelays.length, |
||||
outboxRelays: outboxRelays.length |
||||
}); |
||||
|
||||
// Create a map of all relays with their categories |
||||
const relayCategories = new Map<string, CategorizedRelay>(); |
||||
|
||||
// Process inbox relays (up to top 3) |
||||
const topInboxRelays = inboxRelays.slice(0, 3); |
||||
topInboxRelays.forEach((relay: string) => { |
||||
const isOutbox = outbox.has(relay); |
||||
if (isOutbox) { |
||||
relayCategories.set(relay, { relay, category: 'both', label: 'Inbox & Outbox' }); |
||||
} else { |
||||
relayCategories.set(relay, { relay, category: 'inbox', label: 'Recipient Inbox' }); |
||||
} |
||||
}); |
||||
|
||||
// Process outbox relays (up to top 3) |
||||
const topOutboxRelays = outboxRelays.slice(0, 3); |
||||
topOutboxRelays.forEach((relay: string) => { |
||||
if (!relayCategories.has(relay)) { |
||||
relayCategories.set(relay, { relay, category: 'outbox', label: 'Sender Outbox' }); |
||||
} |
||||
}); |
||||
|
||||
// Only include relays that are actually in the top-3 lists |
||||
// This ensures we only show the intended top-3 inboxes and top-3 outboxes |
||||
const categorized = Array.from(relayCategories.values()); |
||||
console.log('[RelayInfoList] Categorized relays count:', categorized.length); |
||||
return categorized; |
||||
}); |
||||
|
||||
// Group by category |
||||
const groupedRelays = $derived(() => { |
||||
const categorized = categorizedRelays(); |
||||
console.log('[RelayInfoList] Grouping categorized relays'); |
||||
|
||||
const groups = { |
||||
both: categorized.filter((r: CategorizedRelay) => r.category === 'both'), |
||||
inbox: categorized.filter((r: CategorizedRelay) => r.category === 'inbox'), |
||||
outbox: categorized.filter((r: CategorizedRelay) => r.category === 'outbox'), |
||||
other: categorized.filter((r: CategorizedRelay) => r.category === 'other') |
||||
}; |
||||
|
||||
console.log('[RelayInfoList] Grouped relays:', Object.fromEntries( |
||||
Object.entries(groups).map(([key, relays]) => [key, relays.length]) |
||||
)); |
||||
return groups; |
||||
}); |
||||
|
||||
async function loadRelayInfos() { |
||||
isLoading = true; |
||||
try { |
||||
const categorized = categorizedRelays(); |
||||
const relayUrls = categorized.map(r => r.relay); |
||||
relayInfos = await fetchRelayInfos(relayUrls); |
||||
} catch (error) { |
||||
console.warn('[RelayInfoList] Error loading relay infos:', error); |
||||
} finally { |
||||
isLoading = false; |
||||
} |
||||
} |
||||
|
||||
// Load relay info when categorized relays change |
||||
$effect(() => { |
||||
const categorized = categorizedRelays(); |
||||
console.log('[RelayInfoList] Categorized relays changed:', { |
||||
total: categorized.length, |
||||
byCategory: Object.fromEntries( |
||||
Object.entries(groupedRelays()).map(([key, relays]) => [key, relays.length]) |
||||
) |
||||
}); |
||||
if (categorized.length > 0) { |
||||
loadRelayInfos(); |
||||
} |
||||
}); |
||||
|
||||
// Get relay info for a specific relay |
||||
function getRelayInfo(relayUrl: string): RelayInfoWithMetadata | undefined { |
||||
return relayInfos.find(info => info.url === relayUrl); |
||||
} |
||||
|
||||
// Category colors |
||||
const categoryColors = { |
||||
both: 'bg-green-100 dark:bg-green-900 border-green-200 dark:border-green-700 text-green-800 dark:text-green-200', |
||||
inbox: 'bg-blue-100 dark:bg-blue-900 border-blue-200 dark:border-blue-700 text-blue-800 dark:text-blue-200', |
||||
outbox: 'bg-purple-100 dark:bg-purple-900 border-purple-200 dark:border-purple-700 text-purple-800 dark:text-purple-200', |
||||
other: 'bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-200' |
||||
}; |
||||
|
||||
const categoryIcons = { |
||||
both: '🔄', |
||||
inbox: '📥', |
||||
outbox: '📤', |
||||
other: '🌐' |
||||
}; |
||||
</script> |
||||
|
||||
<div class="space-y-2"> |
||||
{#if showLabels && !compact} |
||||
{@const categorizedCount = categorizedRelays().length} |
||||
{@const debugCategorized = console.log('[RelayInfoList] Debug - categorized relays:', categorizedRelays())} |
||||
<div class="text-sm font-medium text-gray-700 dark:text-gray-300"> |
||||
Publishing to {categorizedCount} relay(s): |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if isLoading} |
||||
<div class="flex items-center justify-center py-2"> |
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div> |
||||
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Loading relay info...</span> |
||||
</div> |
||||
{:else} |
||||
{@const categorized = categorizedRelays()} |
||||
{@const debugCategorized = console.log('[RelayInfoList] Debug - categorized relays:', categorized)} |
||||
|
||||
<div class="space-y-1"> |
||||
{#each categorized as { relay, category, label }} |
||||
<div class="p-2 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700"> |
||||
<div class="flex items-center justify-between"> |
||||
<span class="text-sm font-mono text-gray-900 dark:text-gray-100"> |
||||
{relay} |
||||
</span> |
||||
{#if category === 'both'} |
||||
<span class="text-xs text-gray-500 dark:text-gray-400 italic"> |
||||
common relay |
||||
</span> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
@ -0,0 +1,252 @@
@@ -0,0 +1,252 @@
|
||||
import { get } from "svelte/store"; |
||||
import { ndkInstance } from "../ndk"; |
||||
import { userStore } from "../stores/userStore"; |
||||
import { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; |
||||
import type NDK from "@nostr-dev-kit/ndk"; |
||||
import { nip19 } from "nostr-tools"; |
||||
|
||||
/** |
||||
* Fetches user's outbox relays from NIP-65 relay list |
||||
* @param ndk NDK instance |
||||
* @param user User to fetch outbox relays for |
||||
* @returns Promise that resolves to array of outbox relay URLs |
||||
*/ |
||||
async function getUseroutboxRelays(ndk: NDK, user: NDKUser): Promise<string[]> { |
||||
try { |
||||
console.debug('[kind24_utils] Fetching outbox relays for user:', user.pubkey); |
||||
const relayList = await ndk.fetchEvent( |
||||
{ |
||||
kinds: [10002], |
||||
authors: [user.pubkey], |
||||
} |
||||
); |
||||
|
||||
if (!relayList) { |
||||
console.debug('[kind24_utils] No relay list found for user'); |
||||
return []; |
||||
} |
||||
|
||||
console.debug('[kind24_utils] Found relay list event:', relayList.id); |
||||
console.debug('[kind24_utils] Relay list tags:', relayList.tags); |
||||
|
||||
const outboxRelays: string[] = []; |
||||
relayList.tags.forEach((tag) => { |
||||
console.debug('[kind24_utils] Processing tag:', tag); |
||||
if (tag[0] === 'r' && tag[1]) { |
||||
// NIP-65: r tags with optional inbox/outbox markers
|
||||
const marker = tag[2]; |
||||
if (!marker || marker === 'outbox' || marker === 'inbox') { |
||||
// If no marker or marker is 'outbox', it's a outbox relay
|
||||
// If marker is 'inbox', it's also a outbox relay (NIP-65 allows both)
|
||||
outboxRelays.push(tag[1]); |
||||
console.debug('[kind24_utils] Added outbox relay:', tag[1]); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
console.debug('[kind24_utils] Final outbox relays:', outboxRelays); |
||||
return outboxRelays; |
||||
} catch (error) { |
||||
console.info('[kind24_utils] Error fetching user outbox relays:', error); |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Fetches user's inbox relays from NIP-65 relay list |
||||
* @param ndk NDK instance |
||||
* @param user User to fetch inbox relays for |
||||
* @returns Promise that resolves to array of inbox relay URLs |
||||
*/ |
||||
async function getUserinboxRelays(ndk: NDK, user: NDKUser): Promise<string[]> { |
||||
try { |
||||
console.debug('[kind24_utils] Fetching inbox relays for user:', user.pubkey); |
||||
const relayList = await ndk.fetchEvent( |
||||
{ |
||||
kinds: [10002], |
||||
authors: [user.pubkey], |
||||
} |
||||
); |
||||
|
||||
if (!relayList) { |
||||
console.debug('[kind24_utils] No relay list found for user'); |
||||
return []; |
||||
} |
||||
|
||||
console.debug('[kind24_utils] Found relay list event:', relayList.id); |
||||
console.debug('[kind24_utils] Relay list tags:', relayList.tags); |
||||
|
||||
const inboxRelays: string[] = []; |
||||
relayList.tags.forEach((tag) => { |
||||
console.debug('[kind24_utils] Processing tag:', tag); |
||||
if (tag[0] === 'r' && tag[1]) { |
||||
// NIP-65: r tags with optional inbox/outbox markers
|
||||
const marker = tag[2]; |
||||
if (!marker || marker === 'inbox' || marker === 'outbox') { |
||||
// If no marker or marker is 'inbox', it's a inbox relay
|
||||
// If marker is 'outbox', it's also a inbox relay (NIP-65 allows both)
|
||||
inboxRelays.push(tag[1]); |
||||
console.debug('[kind24_utils] Added inbox relay:', tag[1]); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
console.debug('[kind24_utils] Final inbox relays:', inboxRelays); |
||||
return inboxRelays; |
||||
} catch (error) { |
||||
console.info('[kind24_utils] Error fetching user inbox relays:', error); |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Creates a kind 24 public message reply according to NIP-A4 |
||||
* @param content The message content |
||||
* @param recipientPubkey The recipient's pubkey |
||||
* @param originalEvent The original event being replied to (optional) |
||||
* @returns Promise resolving to publish result with relay information |
||||
*/ |
||||
export async function createKind24Reply( |
||||
content: string, |
||||
recipientPubkey: string, |
||||
originalEvent?: NDKEvent |
||||
): Promise<{ success: boolean; eventId?: string; error?: string; relays?: string[] }> { |
||||
const ndk = get(ndkInstance); |
||||
if (!ndk?.activeUser) { |
||||
return { success: false, error: "Not logged in" }; |
||||
} |
||||
|
||||
if (!content.trim()) { |
||||
return { success: false, error: "Message content cannot be empty" }; |
||||
} |
||||
|
||||
try { |
||||
// Get sender's outbox relays (NIP-65)
|
||||
const senderoutboxRelays = await getUseroutboxRelays(ndk, ndk.activeUser); |
||||
|
||||
// Get recipient's inbox relays (NIP-65)
|
||||
const recipientUser = ndk.getUser({ pubkey: recipientPubkey }); |
||||
const recipientinboxRelays = await getUserinboxRelays(ndk, recipientUser); |
||||
|
||||
// According to NIP-A4: Messages MUST be sent to the NIP-65 inbox relays of each receiver
|
||||
// and the outbox relay of the sender
|
||||
const targetRelays = [...new Set([...senderoutboxRelays, ...recipientinboxRelays])]; |
||||
|
||||
// Prioritize common relays between sender and recipient for better privacy
|
||||
const commonRelays = senderoutboxRelays.filter(relay =>
|
||||
recipientinboxRelays.includes(relay) |
||||
); |
||||
const senderOnlyRelays = senderoutboxRelays.filter(relay =>
|
||||
!recipientinboxRelays.includes(relay) |
||||
); |
||||
const recipientOnlyRelays = recipientinboxRelays.filter(relay =>
|
||||
!senderoutboxRelays.includes(relay) |
||||
); |
||||
|
||||
// Prioritize: common relays first, then sender outbox, then recipient inbox
|
||||
const prioritizedRelays = [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays]; |
||||
|
||||
if (prioritizedRelays.length === 0) { |
||||
return { success: false, error: "No relays available for publishing" }; |
||||
} |
||||
|
||||
// Create the kind 24 event
|
||||
const event = new NDKEvent(ndk); |
||||
event.kind = 24; |
||||
|
||||
// Build content with quoted message if replying
|
||||
let finalContent = content; |
||||
if (originalEvent) { |
||||
// Use multiple relays for better discoverability
|
||||
const nevent = nip19.neventEncode({
|
||||
id: originalEvent.id, |
||||
relays: prioritizedRelays.slice(0, 3) // Use first 3 relays
|
||||
}); |
||||
const quotedContent = originalEvent.content ? originalEvent.content.slice(0, 200) : "No content"; |
||||
// Use a more visible quote format with a clickable link
|
||||
finalContent = `> QUOTED: ${quotedContent}\n> LINK: ${nevent}\n\n${content}`; |
||||
console.log("[kind24_utils] Reply content:", finalContent); |
||||
} |
||||
|
||||
event.content = finalContent; |
||||
event.created_at = Math.floor(Date.now() / 1000); |
||||
|
||||
// Add p tag for recipient with relay URL
|
||||
const tags: string[][] = [ |
||||
["p", recipientPubkey, prioritizedRelays[0]] // Use first relay as primary
|
||||
]; |
||||
|
||||
// Add q tag if replying to an original event
|
||||
if (originalEvent) { |
||||
const nevent = nip19.neventEncode({
|
||||
id: originalEvent.id, |
||||
relays: prioritizedRelays.slice(0, 3) // Use first 3 relays
|
||||
}); |
||||
tags.push(["q", nevent, prioritizedRelays[0]]); |
||||
} |
||||
|
||||
event.tags = tags; |
||||
event.pubkey = ndk.activeUser.pubkey; |
||||
|
||||
// Sign the event
|
||||
await event.sign(); |
||||
|
||||
// Publish to relays
|
||||
const relaySet = NDKRelaySet.fromRelayUrls(prioritizedRelays, ndk); |
||||
const publishedToRelays = await event.publish(relaySet); |
||||
|
||||
if (publishedToRelays.size > 0) { |
||||
return { success: true, eventId: event.id, relays: prioritizedRelays }; |
||||
} else { |
||||
return { success: false, error: "Failed to publish to any relays", relays: prioritizedRelays }; |
||||
} |
||||
} catch (error) { |
||||
console.error("[kind24_utils] Error creating kind 24 reply:", error); |
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error"
|
||||
}; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Gets optimal relay set for kind 24 messages between two users |
||||
* @param senderPubkey The sender's pubkey |
||||
* @param recipientPubkey The recipient's pubkey |
||||
* @returns Promise resolving to relay URLs prioritized by commonality |
||||
*/ |
||||
export async function getKind24RelaySet( |
||||
senderPubkey: string, |
||||
recipientPubkey: string |
||||
): Promise<string[]> { |
||||
const ndk = get(ndkInstance); |
||||
if (!ndk) { |
||||
throw new Error("NDK not available"); |
||||
} |
||||
|
||||
// Get sender's outbox relays (NIP-65)
|
||||
const senderUser = ndk.getUser({ pubkey: senderPubkey }); |
||||
const senderoutboxRelays = await getUseroutboxRelays(ndk, senderUser); |
||||
|
||||
// Get recipient's inbox relays (NIP-65)
|
||||
const recipientUser = ndk.getUser({ pubkey: recipientPubkey }); |
||||
const recipientinboxRelays = await getUserinboxRelays(ndk, recipientUser); |
||||
|
||||
// According to NIP-A4: Messages MUST be sent to the NIP-65 inbox relays of each receiver
|
||||
// and the outbox relay of the sender
|
||||
const targetRelays = [...new Set([...senderoutboxRelays, ...recipientinboxRelays])]; |
||||
|
||||
// Prioritize common relays between sender and recipient for better privacy
|
||||
const commonRelays = senderoutboxRelays.filter((relay: string) =>
|
||||
recipientinboxRelays.includes(relay) |
||||
); |
||||
const senderOnlyRelays = senderoutboxRelays.filter((relay: string) =>
|
||||
!recipientinboxRelays.includes(relay) |
||||
); |
||||
const recipientOnlyRelays = recipientinboxRelays.filter((relay: string) =>
|
||||
!senderoutboxRelays.includes(relay) |
||||
); |
||||
|
||||
// Prioritize: common relays first, then sender outbox, then recipient inbox
|
||||
return [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays]; |
||||
} |
||||
@ -0,0 +1,166 @@
@@ -0,0 +1,166 @@
|
||||
/** |
||||
* Simplifies a URL by removing protocol and common prefixes |
||||
* @param url The URL to simplify |
||||
* @returns Simplified URL string |
||||
*/ |
||||
function simplifyUrl(url: string): string { |
||||
try { |
||||
const urlObj = new URL(url); |
||||
return urlObj.hostname + (urlObj.port ? `:${urlObj.port}` : ''); |
||||
} catch { |
||||
// If URL parsing fails, return the original string
|
||||
return url; |
||||
} |
||||
} |
||||
|
||||
export interface RelayInfo { |
||||
name?: string; |
||||
description?: string; |
||||
icon?: string; |
||||
pubkey?: string; |
||||
contact?: string; |
||||
supported_nips?: number[]; |
||||
software?: string; |
||||
version?: string; |
||||
tags?: string[]; |
||||
payments_url?: string; |
||||
limitation?: { |
||||
auth_required?: boolean; |
||||
payment_required?: boolean; |
||||
}; |
||||
} |
||||
|
||||
export interface RelayInfoWithMetadata extends RelayInfo { |
||||
url: string; |
||||
shortUrl: string; |
||||
hasNip11: boolean; |
||||
triedNip11: boolean; |
||||
} |
||||
|
||||
/** |
||||
* Fetches relay information using NIP-11 |
||||
* @param url The relay URL to fetch info for |
||||
* @returns Promise resolving to relay info or undefined if failed |
||||
*/ |
||||
export async function fetchRelayInfo(url: string): Promise<RelayInfoWithMetadata | undefined> { |
||||
try { |
||||
// Convert WebSocket URL to HTTP URL for NIP-11
|
||||
const httpUrl = url.replace('ws://', 'http://').replace('wss://', 'https://'); |
||||
|
||||
const response = await fetch(httpUrl, { |
||||
headers: {
|
||||
'Accept': 'application/nostr+json', |
||||
'User-Agent': 'Alexandria/1.0' |
||||
}, |
||||
// Add timeout to prevent hanging
|
||||
signal: AbortSignal.timeout(5000) |
||||
}); |
||||
|
||||
if (!response.ok) { |
||||
console.warn(`[RelayInfo] HTTP ${response.status} for ${url}`); |
||||
return { |
||||
url, |
||||
shortUrl: simplifyUrl(url), |
||||
hasNip11: false, |
||||
triedNip11: true |
||||
}; |
||||
} |
||||
|
||||
const relayInfo = await response.json() as RelayInfo; |
||||
|
||||
return { |
||||
...relayInfo, |
||||
url, |
||||
shortUrl: simplifyUrl(url), |
||||
hasNip11: Object.keys(relayInfo).length > 0, |
||||
triedNip11: true |
||||
}; |
||||
} catch (error) { |
||||
console.warn(`[RelayInfo] Failed to fetch info for ${url}:`, error); |
||||
return { |
||||
url, |
||||
shortUrl: simplifyUrl(url), |
||||
hasNip11: false, |
||||
triedNip11: true |
||||
}; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Fetches relay information for multiple relays in parallel |
||||
* @param urls Array of relay URLs to fetch info for |
||||
* @returns Promise resolving to array of relay info objects |
||||
*/ |
||||
export async function fetchRelayInfos(urls: string[]): Promise<RelayInfoWithMetadata[]> { |
||||
if (urls.length === 0) { |
||||
return []; |
||||
} |
||||
|
||||
const promises = urls.map(url => fetchRelayInfo(url)); |
||||
const results = await Promise.allSettled(promises); |
||||
|
||||
return results |
||||
.map(result => result.status === 'fulfilled' ? result.value : undefined) |
||||
.filter((info): info is RelayInfoWithMetadata => info !== undefined); |
||||
} |
||||
|
||||
/** |
||||
* Gets relay type label based on relay URL and info |
||||
* @param relayUrl The relay URL |
||||
* @param relayInfo Optional relay info |
||||
* @returns String describing the relay type |
||||
*/ |
||||
export function getRelayTypeLabel(relayUrl: string, relayInfo?: RelayInfoWithMetadata): string { |
||||
// Check if it's a local relay
|
||||
if (relayUrl.includes('localhost') || relayUrl.includes('127.0.0.1')) { |
||||
return 'Local'; |
||||
} |
||||
|
||||
// Check if it's a community relay
|
||||
if (relayUrl.includes('nostr.band') || relayUrl.includes('noswhere.com') ||
|
||||
relayUrl.includes('damus.io') || relayUrl.includes('nostr.wine')) { |
||||
return 'Community'; |
||||
} |
||||
|
||||
// Check if it's a user's relay (likely inbox/outbox)
|
||||
if (relayUrl.includes('relay.nsec.app') || relayUrl.includes('relay.snort.social')) { |
||||
return 'User'; |
||||
} |
||||
|
||||
// Use relay name if available
|
||||
if (relayInfo?.name) { |
||||
return relayInfo.name; |
||||
} |
||||
|
||||
// Fallback to domain
|
||||
try { |
||||
const domain = new URL(relayUrl).hostname; |
||||
return domain.replace('www.', ''); |
||||
} catch { |
||||
return 'Unknown'; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Gets relay icon URL or fallback |
||||
* @param relayInfo Relay info object |
||||
* @param relayUrl Relay URL as fallback |
||||
* @returns Icon URL or undefined |
||||
*/ |
||||
export function getRelayIcon(relayInfo?: RelayInfoWithMetadata, relayUrl?: string): string | undefined { |
||||
if (relayInfo?.icon) { |
||||
return relayInfo.icon; |
||||
} |
||||
|
||||
// Generate favicon URL from relay URL
|
||||
if (relayUrl) { |
||||
try { |
||||
const url = new URL(relayUrl); |
||||
return `${url.protocol}//${url.hostname}/favicon.ico`; |
||||
} catch { |
||||
// Invalid URL, return undefined
|
||||
} |
||||
} |
||||
|
||||
return undefined; |
||||
} |
||||
Loading…
Reference in new issue