diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 867b881..062ecd4 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -11,6 +11,9 @@ import { get } from "svelte/store"; import { nip19 } from "nostr-tools"; import { communityRelays, localRelays } from "$lib/consts"; + import { createKind24Reply, getKind24RelaySet } from "$lib/utils/kind24_utils"; + import RelayDisplay from "$lib/components/RelayDisplay.svelte"; + import RelayInfoList from "$lib/components/RelayInfoList.svelte"; const { event } = $props<{ event: NDKEvent }>(); @@ -23,6 +26,14 @@ let notificationMode = $state<"to-me" | "from-me" | "public-messages">("to-me"); let authorProfiles = $state>(new Map()); let filteredByUser = $state(null); + let replyContent = $state(""); + let replyingTo = $state(null); + let isReplying = $state(false); + let originalMessage = $state(null); + let replyingToMessageId = $state(null); + let replyRelays = $state([]); + let senderOutboxRelays = $state([]); + let recipientInboxRelays = $state([]); // Derived state for filtered messages let filteredMessages = $derived.by(() => { @@ -85,6 +96,19 @@ return content.slice(0, maxLength) + "..."; } + function renderContentWithLinks(content: string): string { + console.log("[Notifications] Rendering content:", content); + + // Parse markdown links [text](url) and convert to HTML + let rendered = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Also handle the new quote format: "> LINK: nevent://..." and convert to button + rendered = rendered.replace(/> LINK: (nevent:\/\/[^\s\n]+)/g, '> '); + + console.log("[Notifications] Rendered content:", rendered); + return rendered; + } + function getNotificationType(event: NDKEvent): string { switch (event.kind) { case 1: return "Reply"; @@ -109,6 +133,141 @@ filteredByUser = null; } + // AI-NOTE: Reply functionality for kind 24 messages + async function startReply(pubkey: string, messageEvent?: NDKEvent) { + replyingTo = pubkey; + isReplying = true; + replyContent = ""; + replyingToMessageId = messageEvent?.id || null; + // Store the original message for q tag + originalMessage = messageEvent || null; + // Clear previous relay information + replyRelays = []; + senderOutboxRelays = []; + recipientInboxRelays = []; + + // Immediately fetch relay information for this recipient + await getReplyRelays(); + } + + function cancelReply() { + replyingTo = null; + isReplying = false; + replyContent = ""; + replyingToMessageId = null; + replyRelays = []; + senderOutboxRelays = []; + recipientInboxRelays = []; + } + + async function sendReply() { + if (!replyingTo || !replyContent.trim()) return; + + try { + // Find the original message being replied to + const originalMessage = publicMessages.find(msg => msg.id === replyingToMessageId); + const result = await createKind24Reply(replyContent, replyingTo, originalMessage); + + if (result.success) { + // Store relay information for display + replyRelays = result.relays || []; + + // Update the inbox/outbox arrays to match the actual relays being used + // Keep only the top 3 that are actually in the reply relay set + const replyRelaySet = new Set(replyRelays); + senderOutboxRelays = senderOutboxRelays + .filter(relay => replyRelaySet.has(relay)) + .slice(0, 3); + recipientInboxRelays = recipientInboxRelays + .filter(relay => replyRelaySet.has(relay)) + .slice(0, 3); + + // Clear reply state + replyingTo = null; + isReplying = false; + replyContent = ""; + replyingToMessageId = null; + // Optionally refresh messages + await fetchPublicMessages(); + } else { + console.error("Failed to send reply:", result.error); + // You could show an error message to the user here + } + } catch (error) { + console.error("Error sending reply:", error); + } + } + + // Function to get relay information before sending + async function getReplyRelays() { + if (!replyingTo) return; + + try { + const originalMessage = publicMessages.find(msg => msg.id === replyingToMessageId); + + // Get sender's outbox relays and recipient's inbox relays + const ndk = get(ndkInstance); + if (ndk?.activeUser) { + // Get sender's outbox relays + const senderUser = ndk.activeUser; + const senderRelayList = await ndk.fetchEvent({ + kinds: [10002], + authors: [senderUser.pubkey], + }); + + if (senderRelayList) { + senderOutboxRelays = senderRelayList.tags + .filter(tag => tag[0] === 'r' && tag[1]) + .map(tag => tag[1]) + .slice(0, 3); // Limit to top 3 outbox relays + } + + // Get recipient's inbox relays + const recipientUser = ndk.getUser({ pubkey: replyingTo }); + const recipientRelayList = await ndk.fetchEvent({ + kinds: [10002], + authors: [replyingTo], + }); + + if (recipientRelayList) { + recipientInboxRelays = recipientRelayList.tags + .filter(tag => tag[0] === 'r' && tag[1]) + .map(tag => tag[1]) + .slice(0, 3); // Limit to top 3 inbox relays + } + } + + // If we have content, use the actual reply function + if (replyContent.trim()) { + const result = await createKind24Reply(replyContent, replyingTo, originalMessage); + replyRelays = result.relays || []; + } else { + // If no content yet, just get the relay set for this recipient + const result = await getKind24RelaySet($userStore.pubkey || '', replyingTo); + replyRelays = result || []; + + // Update the inbox/outbox arrays to match the actual relays being used + // Keep only the top 3 that are actually in the reply relay set + const replyRelaySet = new Set(replyRelays); + senderOutboxRelays = senderOutboxRelays + .filter(relay => replyRelaySet.has(relay)) + .slice(0, 3); + recipientInboxRelays = recipientInboxRelays + .filter(relay => replyRelaySet.has(relay)) + .slice(0, 3); + + console.log('[Notifications] Got relay set:', result); + console.log('[Notifications] Filtered sender outbox relays:', senderOutboxRelays); + console.log('[Notifications] Filtered recipient inbox relays:', recipientInboxRelays); + } + } catch (error) { + console.error("Error getting relay information:", error); + replyRelays = []; + senderOutboxRelays = []; + recipientInboxRelays = []; + } + } + // AI-NOTE: Simplified profile fetching with better error handling async function fetchAuthorProfiles(events: NDKEvent[]) { const uniquePubkeys = new Set(); @@ -321,6 +480,13 @@ authorProfiles.clear(); } }); + + // Fetch relay information when reply content changes (for updates) + $effect(() => { + if (isReplying && replyingTo && replyContent.trim() && replyRelays.length === 0) { + getReplyRelays(); + } + }); {#if isOwnProfile && $userStore.signedIn} @@ -400,7 +566,19 @@ {#if !isFromUser} -
+
+ + +
+ + + {#if isReplying && replyingToMessageId === message.id} + {@const recipientProfile = authorProfiles.get(message.pubkey)} +
+
+ + Replying to: {recipientProfile?.displayName || recipientProfile?.name || `${message.pubkey.slice(0, 8)}...${message.pubkey.slice(-4)}`} + + +
+
+ + +
+ + +
+ {#if replyRelays.length > 0} + {@const debugInfo = console.log('[Notifications] Rendering RelayInfoList with:', { replyRelays, recipientInboxRelays, senderOutboxRelays })} + + {:else} +
+
+ Loading relay information... +
+ {/if} +
+
+ {/if} {/each} diff --git a/src/lib/components/RelayInfoDisplay.svelte b/src/lib/components/RelayInfoDisplay.svelte new file mode 100644 index 0000000..c72dc24 --- /dev/null +++ b/src/lib/components/RelayInfoDisplay.svelte @@ -0,0 +1,92 @@ + + +
+ {#if showIcon && relayIcon} + Relay icon (e.target as HTMLImageElement).style.display = 'none'} + /> + {:else if showIcon} + +
+ + + +
+ {/if} + +
+ {#if showName} + + {isLoading ? 'Loading...' : displayName} + + {/if} + + {#if showType} + + {relayType} + + {/if} +
+ + {#if error} + + ⚠️ + + {/if} +
diff --git a/src/lib/components/RelayInfoList.svelte b/src/lib/components/RelayInfoList.svelte new file mode 100644 index 0000000..0443453 --- /dev/null +++ b/src/lib/components/RelayInfoList.svelte @@ -0,0 +1,169 @@ + + +
+ {#if showLabels && !compact} + {@const categorizedCount = categorizedRelays().length} + {@const debugCategorized = console.log('[RelayInfoList] Debug - categorized relays:', categorizedRelays())} +
+ Publishing to {categorizedCount} relay(s): +
+ {/if} + + {#if isLoading} +
+
+ Loading relay info... +
+ {:else} + {@const categorized = categorizedRelays()} + {@const debugCategorized = console.log('[RelayInfoList] Debug - categorized relays:', categorized)} + +
+ {#each categorized as { relay, category, label }} +
+
+ + {relay} + + {#if category === 'both'} + + common relay + + {/if} +
+
+ {/each} +
+ {/if} +
diff --git a/src/lib/utils/kind24_utils.ts b/src/lib/utils/kind24_utils.ts new file mode 100644 index 0000000..e3b6e14 --- /dev/null +++ b/src/lib/utils/kind24_utils.ts @@ -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 { + 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 { + 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 { + 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]; +} diff --git a/src/lib/utils/relay_info_service.ts b/src/lib/utils/relay_info_service.ts new file mode 100644 index 0000000..8b978a0 --- /dev/null +++ b/src/lib/utils/relay_info_service.ts @@ -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 { + 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 { + 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; +}