diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 81c087b..c569732 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -38,3 +38,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771664126,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","update profile page, dashboard, and connections"]],"content":"Signed commit: update profile page, dashboard, and connections","id":"862b888e52bf4fc3e53c80afd9f301b22ce674366f48d006bca520479394c0f9","sig":"c2e895f67ff5a68e87dcdc54a0312e169f4729a05a62f1ffbe92afd6e57b7d232b36ef4291c07969e531cdc4f22f5ac32723a2aecc57a0b613b945217ecc651a"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771664339,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","added lightning address copy button"]],"content":"Signed commit: added lightning address copy button","id":"f0973d13a903f64895d265643390fe54bd86fe492a53c3ffea303dad8cf8a2f6","sig":"8c98969c5755bf8742733e05ca4be53f4f3ba276a2445ee7b903e443947fc53808b046c188dd91f26b6dcaecbe93585e1f2539855c8eba57e17a915e81bfa2d4"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771668002,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","finish profile page"]],"content":"Signed commit: finish profile page","id":"8a5aed2f8ac370f781dca9db96ade991c18b7cc3b0d27149d9e2741e8276f16f","sig":"16e9a9242f7c22dab8e37fd9d618419b4d51d7c0156f52c1289e275d2528312f4006696473c6836b5a661425fe0412fe54127291fb9b0d14777f93c8228cffb0"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771669826,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","user badge is a universal hyperlink to the profile page"]],"content":"Signed commit: user badge is a universal hyperlink to the profile page","id":"973a406714e586037d81cca323024ff5e2cc1fbaeda8846f6f2994c3829c4fe0","sig":"e7a58526a3786fc1b9ab1f957c87c13a42d3c2cc95effcf4ce4f4710e01ecc45fcff3ca542c5fa223961d7b99fe336a2851c133aebe3bfc1a591ffe1c34b221a"} diff --git a/src/lib/components/UserBadge.svelte b/src/lib/components/UserBadge.svelte index 6b8555e..3a1c659 100644 --- a/src/lib/components/UserBadge.svelte +++ b/src/lib/components/UserBadge.svelte @@ -13,27 +13,26 @@ let { pubkey, disableLink = false }: Props = $props(); - // Convert pubkey to npub for navigation - function getNpub(): string { + // Convert pubkey to npub for navigation (reactive) + const profileUrl = $derived.by(() => { try { // Check if already npub format try { const decoded = nip19.decode(pubkey); if (decoded.type === 'npub') { - return pubkey; + return `/users/${pubkey}`; } } catch { // Not an npub, continue to encode } // Convert hex pubkey to npub - return nip19.npubEncode(pubkey); + const npub = nip19.npubEncode(pubkey); + return `/users/${npub}`; } catch { // If all fails, return as-is (will be handled by route) - return pubkey; + return `/users/${pubkey}`; } - } - - const profileUrl = `/users/${getNpub()}`; + }); let userProfile = $state<{ name?: string; picture?: string } | null>(null); let loading = $state(true); @@ -194,7 +193,13 @@ {truncateHandle(userProfile?.name)} {:else} - + { + e.stopPropagation(); + }} + > {#if userProfile?.picture} Profile {:else} diff --git a/src/lib/types/nostr.ts b/src/lib/types/nostr.ts index 05431c4..8fe9ef7 100644 --- a/src/lib/types/nostr.ts +++ b/src/lib/types/nostr.ts @@ -58,6 +58,8 @@ export const KIND = { PUBLIC_MESSAGE: 24, // NIP-24: Public message (direct chat) PROFILE_METADATA: 0, // NIP-01: User metadata REPOST: 6, // NIP-18: Repost + ZAP_REQUEST: 9734, // NIP-57: Lightning zap request + ZAP_RECEIPT: 9735, // NIP-57: Lightning zap receipt } as const; /** diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 75a5c74..724d11f 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -364,6 +364,302 @@ }>>([]); let loadingDiscussions = $state(false); + // Discussion events cache for reply/quote blurbs + let discussionEvents = $state>(new Map()); + + // Nostr link cache for embedded events and profiles + let nostrLinkEvents = $state>(new Map()); + let nostrLinkProfiles = $state>(new Map()); // npub -> pubkey hex + + // Parse nostr: links from content and extract IDs/pubkeys + function parseNostrLinks(content: string): Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> { + const links: Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> = []; + const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|profile1)[a-zA-Z0-9]+/g; + let match; + + while ((match = nostrLinkRegex.exec(content)) !== null) { + const fullMatch = match[0]; + const prefix = match[1]; + let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; + + if (prefix === 'nevent1') type = 'nevent'; + else if (prefix === 'naddr1') type = 'naddr'; + else if (prefix === 'note1') type = 'note1'; + else if (prefix === 'npub1') type = 'npub'; + else if (prefix === 'profile1') type = 'profile'; + else continue; + + links.push({ + type, + value: fullMatch, + start: match.index, + end: match.index + fullMatch.length + }); + } + + return links; + } + + // Load events/profiles from nostr: links + async function loadNostrLinks(content: string) { + const links = parseNostrLinks(content); + if (links.length === 0) return; + + const eventIds: string[] = []; + const aTags: string[] = []; + const npubs: string[] = []; + + for (const link of links) { + try { + if (link.type === 'nevent' || link.type === 'note1') { + const decoded = nip19.decode(link.value.replace('nostr:', '')); + if (decoded.type === 'nevent') { + eventIds.push(decoded.data.id); + } else if (decoded.type === 'note') { + eventIds.push(decoded.data as string); + } + } else if (link.type === 'naddr') { + const decoded = nip19.decode(link.value.replace('nostr:', '')); + if (decoded.type === 'naddr') { + const aTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`; + aTags.push(aTag); + } + } else if (link.type === 'npub' || link.type === 'profile') { + const decoded = nip19.decode(link.value.replace('nostr:', '')); + if (decoded.type === 'npub') { + npubs.push(link.value); + nostrLinkProfiles.set(link.value, decoded.data as string); + } + } + } catch { + // Invalid nostr link, skip + } + } + + // Fetch events + if (eventIds.length > 0) { + try { + const events = await Promise.race([ + nostrClient.fetchEvents([{ ids: eventIds, limit: eventIds.length }]), + new Promise((resolve) => setTimeout(() => resolve([]), 10000)) + ]); + + for (const event of events) { + nostrLinkEvents.set(event.id, event); + } + } catch { + // Ignore fetch errors + } + } + + // Fetch a-tag events + if (aTags.length > 0) { + for (const aTag of aTags) { + const parts = aTag.split(':'); + if (parts.length === 3) { + try { + const kind = parseInt(parts[0]); + const pubkey = parts[1]; + const dTag = parts[2]; + const events = await Promise.race([ + nostrClient.fetchEvents([{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }]), + new Promise((resolve) => setTimeout(() => resolve([]), 10000)) + ]); + + if (events.length > 0) { + nostrLinkEvents.set(events[0].id, events[0]); + } + } catch { + // Ignore fetch errors + } + } + } + } + } + + // Get event from nostr: link + function getEventFromNostrLink(link: string): NostrEvent | undefined { + try { + if (link.startsWith('nostr:nevent1') || link.startsWith('nostr:note1')) { + const decoded = nip19.decode(link.replace('nostr:', '')); + if (decoded.type === 'nevent') { + return nostrLinkEvents.get(decoded.data.id); + } else if (decoded.type === 'note') { + return nostrLinkEvents.get(decoded.data as string); + } + } else if (link.startsWith('nostr:naddr1')) { + const decoded = nip19.decode(link.replace('nostr:', '')); + if (decoded.type === 'naddr') { + const eventId = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`; + return Array.from(nostrLinkEvents.values()).find(e => { + const dTag = e.tags.find(t => t[0] === 'd')?.[1]; + return e.kind === decoded.data.kind && + e.pubkey === decoded.data.pubkey && + dTag === decoded.data.identifier; + }); + } + } + } catch { + // Invalid link + } + return undefined; + } + + // Get pubkey from nostr: npub/profile link + function getPubkeyFromNostrLink(link: string): string | undefined { + return nostrLinkProfiles.get(link); + } + + // Process content with nostr links into parts for rendering + function processContentWithNostrLinks(content: string): Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> { + const links = parseNostrLinks(content); + if (links.length === 0) { + return [{ type: 'text', value: content }]; + } + + const parts: Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> = []; + let lastIndex = 0; + + for (const link of links) { + // Add text before link + if (link.start > lastIndex) { + const textPart = content.slice(lastIndex, link.start); + if (textPart) { + parts.push({ type: 'text', value: textPart }); + } + } + + // Add link + const event = getEventFromNostrLink(link.value); + const pubkey = getPubkeyFromNostrLink(link.value); + if (event) { + parts.push({ type: 'event', value: link.value, event }); + } else if (pubkey) { + parts.push({ type: 'profile', value: link.value, pubkey }); + } else { + parts.push({ type: 'placeholder', value: link.value }); + } + + lastIndex = link.end; + } + + // Add remaining text + if (lastIndex < content.length) { + const textPart = content.slice(lastIndex); + if (textPart) { + parts.push({ type: 'text', value: textPart }); + } + } + + return parts; + } + + // Load full events for discussions and comments to get tags for blurbs + async function loadDiscussionEvents(discussionsList: typeof discussions) { + const eventIds = new Set(); + + // Collect all event IDs + for (const discussion of discussionsList) { + if (discussion.id) { + eventIds.add(discussion.id); + } + if (discussion.comments) { + for (const comment of discussion.comments) { + if (comment.id) { + eventIds.add(comment.id); + } + if (comment.replies) { + for (const reply of comment.replies) { + if (reply.id) { + eventIds.add(reply.id); + } + if (reply.replies) { + for (const nestedReply of reply.replies) { + if (nestedReply.id) { + eventIds.add(nestedReply.id); + } + } + } + } + } + } + } + } + + if (eventIds.size === 0) return; + + try { + const events = await Promise.race([ + nostrClient.fetchEvents([{ ids: Array.from(eventIds), limit: eventIds.size }]), + new Promise((resolve) => setTimeout(() => resolve([]), 10000)) + ]); + + for (const event of events) { + discussionEvents.set(event.id, event); + } + } catch { + // Ignore fetch errors + } + } + + // Get discussion event by ID + function getDiscussionEvent(eventId: string): NostrEvent | undefined { + return discussionEvents.get(eventId); + } + + // Get referenced event from discussion event (e-tag, a-tag, q-tag) + function getReferencedEventFromDiscussion(event: NostrEvent): NostrEvent | undefined { + // Check e-tag + const eTag = event.tags.find(t => t[0] === 'e' && t[1])?.[1]; + if (eTag) { + const referenced = discussionEvents.get(eTag); + if (referenced) return referenced; + } + + // Check a-tag + const aTag = event.tags.find(t => t[0] === 'a' && t[1])?.[1]; + if (aTag) { + const parts = aTag.split(':'); + if (parts.length === 3) { + const kind = parseInt(parts[0]); + const pubkey = parts[1]; + const dTag = parts[2]; + return Array.from(discussionEvents.values()).find(e => + e.kind === kind && + e.pubkey === pubkey && + e.tags.find(t => t[0] === 'd' && t[1] === dTag) + ); + } + } + + // Check q-tag + const qTag = event.tags.find(t => t[0] === 'q' && t[1])?.[1]; + if (qTag) { + return discussionEvents.get(qTag); + } + + return undefined; + } + + // Format time for discussions + function formatDiscussionTime(timestamp: number): string { + const date = new Date(timestamp * 1000); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); + } + + // Create a nostrClient instance for fetching events + let nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + // README let readmeContent = $state(null); let readmePath = $state(null); @@ -913,6 +1209,37 @@ pubkey: entry.pubkey, comments: entry.comments })); + + // Fetch full events for discussions and comments to get tags for blurbs + await loadDiscussionEvents(discussions); + + // Fetch nostr: links from discussion content + for (const discussion of discussions) { + if (discussion.content) { + await loadNostrLinks(discussion.content); + } + if (discussion.comments) { + for (const comment of discussion.comments) { + if (comment.content) { + await loadNostrLinks(comment.content); + } + if (comment.replies) { + for (const reply of comment.replies) { + if (reply.content) { + await loadNostrLinks(reply.content); + } + if (reply.replies) { + for (const nestedReply of reply.replies) { + if (nestedReply.content) { + await loadNostrLinks(nestedReply.content); + } + } + } + } + } + } + } + } } catch (err) { error = err instanceof Error ? err.message : 'Failed to load discussions'; console.error('Error loading discussions:', err); @@ -1046,6 +1373,7 @@ // Get repo announcement to get the repo address and relays const client = new NostrClient(DEFAULT_NOSTR_RELAYS); + nostrClient = client; // Store for use in other functions const events = await client.fetchEvents([ { kinds: [KIND.REPO_ANNOUNCEMENT], @@ -4216,9 +4544,41 @@ {/if} -
-

{comment.content}

-
+ {#if true} + {@const commentEvent = getDiscussionEvent(comment.id)} + {@const referencedEvent = commentEvent ? getReferencedEventFromDiscussion(commentEvent) : undefined} + {@const parts = processContentWithNostrLinks(comment.content)} +
+ {#if referencedEvent} +
+
+ + {formatDiscussionTime(referencedEvent.created_at)} +
+
{referencedEvent.content || '(No content)'}
+
+ {/if} +
+ {#each parts as part} + {#if part.type === 'text'} + {part.value} + {:else if part.type === 'event' && part.event} + + {:else if part.type === 'profile' && part.pubkey} + + {:else} + {part.value} + {/if} + {/each} +
+
+ {/if} {#if comment.replies && comment.replies.length > 0}
{#each comment.replies as reply} @@ -4301,9 +4661,41 @@ {/if}
-
-

{comment.content}

-
+ {#if true} + {@const commentEvent = getDiscussionEvent(comment.id)} + {@const referencedEvent = commentEvent ? getReferencedEventFromDiscussion(commentEvent) : undefined} + {@const parts = processContentWithNostrLinks(comment.content)} +
+ {#if referencedEvent} +
+
+ + {formatDiscussionTime(referencedEvent.created_at)} +
+
{referencedEvent.content || '(No content)'}
+
+ {/if} +
+ {#each parts as part} + {#if part.type === 'text'} + {part.value} + {:else if part.type === 'event' && part.event} + + {:else if part.type === 'profile' && part.pubkey} + + {:else} + {part.value} + {/if} + {/each} +
+
+ {/if} {#if comment.replies && comment.replies.length > 0}
{#each comment.replies as reply} @@ -6635,6 +7027,97 @@ line-height: 1.5; } + .referenced-event { + margin-bottom: 1rem; + padding: 0.75rem; + background: var(--bg-primary); + border-left: 3px solid var(--border-color); + border-radius: 0.5rem; + font-size: 0.875rem; + } + + :global([data-theme="light"]) .referenced-event { + background: #e8e8e8; + } + + :global([data-theme="dark"]) .referenced-event { + background: rgba(0, 0, 0, 0.2); + } + + :global([data-theme="black"]) .referenced-event { + background: #0a0a0a; + } + + .referenced-event-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + } + + .referenced-event-time { + font-size: 0.75rem; + color: var(--text-muted); + } + + .referenced-event-content { + color: var(--text-secondary); + white-space: pre-wrap; + word-wrap: break-word; + line-height: 1.5; + max-height: 10rem; + overflow: hidden; + text-overflow: ellipsis; + } + + .nostr-link-event { + margin: 0.75rem 0; + padding: 0.75rem; + background: var(--bg-primary); + border-left: 3px solid var(--border-color); + border-radius: 0.5rem; + font-size: 0.875rem; + } + + :global([data-theme="light"]) .nostr-link-event { + background: #e8e8e8; + } + + :global([data-theme="dark"]) .nostr-link-event { + background: rgba(0, 0, 0, 0.2); + } + + :global([data-theme="black"]) .nostr-link-event { + background: #0a0a0a; + } + + .nostr-link-event-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + } + + .nostr-link-event-time { + font-size: 0.75rem; + color: var(--text-muted); + } + + .nostr-link-event-content { + color: var(--text-secondary); + white-space: pre-wrap; + word-wrap: break-word; + line-height: 1.5; + max-height: 10rem; + overflow: hidden; + text-overflow: ellipsis; + } + + .nostr-link-placeholder { + color: var(--text-muted); + font-style: italic; + } + .nested-replies { margin-left: 2rem; margin-top: 0.75rem; diff --git a/src/routes/users/[npub]/+page.svelte b/src/routes/users/[npub]/+page.svelte index 9f4e105..992c361 100644 --- a/src/routes/users/[npub]/+page.svelte +++ b/src/routes/users/[npub]/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; - import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; + import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } from '$lib/config.js'; import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '$lib/types/nostr.js'; import { getPublicKeyWithNIP07, isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; @@ -46,6 +46,230 @@ let activityEvents = $state([]); let loadingActivity = $state(false); let activityLoaded = $state(false); // Track if we've attempted to load activity + let showReplyDialog = $state(false); + let replyingToEvent = $state(null); + let replyContent = $state(''); + let sendingReply = $state(false); + + // Quoted events cache for messages - use array for better reactivity + let quotedEvents = $state([]); + + // Helper to get quoted event by ID + const getQuotedEvent = (eventId: string): NostrEvent | undefined => { + return quotedEvents.find(e => e.id === eventId); + }; + + // Referenced events cache for activity (a-tags and e-tags) - use array for better reactivity + let referencedEvents = $state([]); + + // Helper to get referenced event by ID or a-tag + const getReferencedEvent = (eventId?: string, aTag?: string): NostrEvent | undefined => { + if (eventId) { + return referencedEvents.find(e => e.id === eventId); + } + if (aTag) { + // Parse a-tag format: kind:pubkey:d-tag + const parts = aTag.split(':'); + if (parts.length === 3) { + const kind = parseInt(parts[0]); + const pubkey = parts[1]; + const dTag = parts[2]; + return referencedEvents.find(e => + e.kind === kind && + e.pubkey === pubkey && + e.tags.find(t => t[0] === 'd' && t[1] === dTag) + ); + } + } + return undefined; + }; + + // Nostr link cache for embedded events and profiles + let nostrLinkEvents = $state>(new Map()); + let nostrLinkProfiles = $state>(new Map()); // npub -> pubkey hex + + // Parse nostr: links from content and extract IDs/pubkeys + function parseNostrLinks(content: string): Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> { + const links: Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> = []; + const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|profile1)[a-zA-Z0-9]+/g; + let match; + + while ((match = nostrLinkRegex.exec(content)) !== null) { + const fullMatch = match[0]; + const prefix = match[1]; + let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; + + if (prefix === 'nevent1') type = 'nevent'; + else if (prefix === 'naddr1') type = 'naddr'; + else if (prefix === 'note1') type = 'note1'; + else if (prefix === 'npub1') type = 'npub'; + else if (prefix === 'profile1') type = 'profile'; + else continue; + + links.push({ + type, + value: fullMatch, + start: match.index, + end: match.index + fullMatch.length + }); + } + + return links; + } + + // Load events/profiles from nostr: links + async function loadNostrLinks(content: string) { + const links = parseNostrLinks(content); + if (links.length === 0) return; + + const eventIds: string[] = []; + const aTags: string[] = []; + const npubs: string[] = []; + + for (const link of links) { + try { + if (link.type === 'nevent' || link.type === 'note1') { + const decoded = nip19.decode(link.value.replace('nostr:', '')); + if (decoded.type === 'nevent') { + eventIds.push(decoded.data.id); + } else if (decoded.type === 'note') { + eventIds.push(decoded.data as string); + } + } else if (link.type === 'naddr') { + const decoded = nip19.decode(link.value.replace('nostr:', '')); + if (decoded.type === 'naddr') { + const aTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`; + aTags.push(aTag); + } + } else if (link.type === 'npub' || link.type === 'profile') { + const decoded = nip19.decode(link.value.replace('nostr:', '')); + if (decoded.type === 'npub') { + npubs.push(link.value); + nostrLinkProfiles.set(link.value, decoded.data as string); + } + } + } catch { + // Invalid nostr link, skip + } + } + + // Fetch events + if (eventIds.length > 0) { + try { + const events = await Promise.race([ + nostrClient.fetchEvents([{ ids: eventIds, limit: eventIds.length }]), + new Promise((resolve) => setTimeout(() => resolve([]), 10000)) + ]); + + for (const event of events) { + nostrLinkEvents.set(event.id, event); + } + } catch { + // Ignore fetch errors + } + } + + // Fetch a-tag events + if (aTags.length > 0) { + for (const aTag of aTags) { + const parts = aTag.split(':'); + if (parts.length === 3) { + try { + const kind = parseInt(parts[0]); + const pubkey = parts[1]; + const dTag = parts[2]; + const events = await Promise.race([ + nostrClient.fetchEvents([{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }]), + new Promise((resolve) => setTimeout(() => resolve([]), 10000)) + ]); + + if (events.length > 0) { + nostrLinkEvents.set(events[0].id, events[0]); + } + } catch { + // Ignore fetch errors + } + } + } + } + } + + // Get event from nostr: link + function getEventFromNostrLink(link: string): NostrEvent | undefined { + try { + if (link.startsWith('nostr:nevent1') || link.startsWith('nostr:note1')) { + const decoded = nip19.decode(link.replace('nostr:', '')); + if (decoded.type === 'nevent') { + return nostrLinkEvents.get(decoded.data.id); + } else if (decoded.type === 'note') { + return nostrLinkEvents.get(decoded.data as string); + } + } else if (link.startsWith('nostr:naddr1')) { + const decoded = nip19.decode(link.replace('nostr:', '')); + if (decoded.type === 'naddr') { + const eventId = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`; + return Array.from(nostrLinkEvents.values()).find(e => { + const dTag = e.tags.find(t => t[0] === 'd')?.[1]; + return e.kind === decoded.data.kind && + e.pubkey === decoded.data.pubkey && + dTag === decoded.data.identifier; + }); + } + } + } catch { + // Invalid link + } + return undefined; + } + + // Get pubkey from nostr: npub/profile link + function getPubkeyFromNostrLink(link: string): string | undefined { + return nostrLinkProfiles.get(link); + } + + // Process content with nostr links into parts for rendering + function processContentWithNostrLinks(content: string): Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> { + const links = parseNostrLinks(content); + if (links.length === 0) { + return [{ type: 'text', value: content }]; + } + + const parts: Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> = []; + let lastIndex = 0; + + for (const link of links) { + // Add text before link + if (link.start > lastIndex) { + const textPart = content.slice(lastIndex, link.start); + if (textPart) { + parts.push({ type: 'text', value: textPart }); + } + } + + // Add link + const event = getEventFromNostrLink(link.value); + const pubkey = getPubkeyFromNostrLink(link.value); + if (event) { + parts.push({ type: 'event', value: link.value, event }); + } else if (pubkey) { + parts.push({ type: 'profile', value: link.value, pubkey }); + } else { + parts.push({ type: 'placeholder', value: link.value }); + } + + lastIndex = link.end; + } + + // Add remaining text + if (lastIndex < content.length) { + const textPart = content.slice(lastIndex); + if (textPart) { + parts.push({ type: 'text', value: textPart }); + } + } + + return parts; + } const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const gitDomain = $page.data.gitDomain || 'localhost:6543'; @@ -357,6 +581,14 @@ i * }; return !shouldExcludeEvent(eventLike, profileOwnerPubkeyHex || ''); }); + + // Fetch quoted events from q-tags + await loadQuotedEvents(messages); + + // Fetch nostr: links from message content + for (const message of messages) { + await loadNostrLinks(message.content); + } } catch (err) { console.error('Error loading messages:', err); } finally { @@ -365,6 +597,48 @@ i * } } + async function loadQuotedEvents(messages: PublicMessage[]) { + // Collect all quoted event IDs from q-tags + const quotedEventIds = new Set(); + for (const message of messages) { + const qTags = message.tags.filter(t => t[0] === 'q' && t[1]); + for (const qTag of qTags) { + if (qTag[1]) { + quotedEventIds.add(qTag[1]); + } + } + } + + if (quotedEventIds.size === 0) return; + + // Fetch events from relays (nostrClient will use cache internally) + try { + // Add timeout to prevent hanging + const fetchPromise = nostrClient.fetchEvents([ + { + ids: Array.from(quotedEventIds), + limit: quotedEventIds.size + } + ]); + + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => resolve([]), 10000); // 10 second timeout + }); + + const fetchedEvents = await Promise.race([fetchPromise, timeoutPromise]); + + // Update cache reactively - add new events, avoid duplicates + const existingIds = new Set(quotedEvents.map(e => e.id)); + const newEvents = fetchedEvents.filter(e => !existingIds.has(e.id)); + if (newEvents.length > 0) { + quotedEvents = [...quotedEvents, ...newEvents]; + } + } catch (err) { + console.warn('Failed to fetch quoted events:', err); + // Don't leave loading state stuck - clear it on error + } + } + async function sendMessage() { if (!newMessageContent.trim() || !viewerPubkeyHex || !profileOwnerPubkeyHex) { alert('Please enter a message and make sure you are logged in'); @@ -674,6 +948,15 @@ i * .sort((a, b) => b.created_at - a.created_at) .slice(0, 200); + // Fetch referenced events from a-tags and e-tags + await loadReferencedEvents(activityEvents); + + // Fetch nostr: links from event content + for (const event of activityEvents) { + if (event.content) { + await loadNostrLinks(event.content); + } + } } catch (err) { console.error('Failed to load activity:', err); error = 'Failed to load activity'; @@ -683,6 +966,91 @@ i * } } + async function loadReferencedEvents(events: NostrEvent[]) { + // Collect all referenced event IDs and a-tags + const eventIds = new Set(); + const aTags = new Set(); + + for (const event of events) { + // Collect e-tags (event references) + const eTags = event.tags.filter(t => t[0] === 'e' && t[1]); + for (const eTag of eTags) { + if (eTag[1]) { + eventIds.add(eTag[1]); + } + } + + // Collect a-tags (addressable event references) + const aTagValues = event.tags.filter(t => t[0] === 'a' && t[1]); + for (const aTag of aTagValues) { + if (aTag[1]) { + aTags.add(aTag[1]); + } + } + } + + if (eventIds.size === 0 && aTags.size === 0) return; + + // Fetch events by ID + const eventsToFetch: Promise[] = []; + + if (eventIds.size > 0) { + eventsToFetch.push( + nostrClient.fetchEvents([ + { + ids: Array.from(eventIds), + limit: eventIds.size + } + ]).catch(() => []) + ); + } + + // Fetch events by a-tags + if (aTags.size > 0) { + for (const aTag of aTags) { + const parts = aTag.split(':'); + if (parts.length === 3) { + const kind = parseInt(parts[0]); + const pubkey = parts[1]; + const dTag = parts[2]; + + eventsToFetch.push( + nostrClient.fetchEvents([ + { + kinds: [kind], + authors: [pubkey], + '#d': [dTag], + limit: 1 + } + ]).catch(() => []) + ); + } + } + } + + // Fetch all referenced events + try { + const fetchPromises = eventsToFetch.map(p => + Promise.race([ + p, + new Promise((resolve) => setTimeout(() => resolve([]), 10000)) + ]) + ); + + const fetchedEventsArrays = await Promise.all(fetchPromises); + const fetchedEvents = fetchedEventsArrays.flat(); + + // Update cache reactively - add new events, avoid duplicates + const existingIds = new Set(referencedEvents.map(e => e.id)); + const newEvents = fetchedEvents.filter(e => !existingIds.has(e.id)); + if (newEvents.length > 0) { + referencedEvents = [...referencedEvents, ...newEvents]; + } + } catch (err) { + console.warn('Failed to fetch referenced events:', err); + } + } + function getEventContext(event: NostrEvent): string { // Special handling for reaction events (kind 7) if (event.kind === 7) { @@ -754,7 +1122,281 @@ i * } } - return kindName; + // Always return something, even if it's just the kind name + return kindName || `Event ${event.id.slice(0, 8)}...`; + } + + function parseRepost(event: NostrEvent): NostrEvent | null { + // Reposts (kind 6) contain the embedded event as JSON in the content field + if (event.kind !== KIND.REPOST || !event.content) { + return null; + } + + try { + const embeddedEvent = JSON.parse(event.content); + // Validate it's a proper Nostr event structure + if (embeddedEvent && typeof embeddedEvent === 'object' && + embeddedEvent.kind !== undefined && + embeddedEvent.pubkey && + embeddedEvent.id) { + return embeddedEvent as NostrEvent; + } + } catch { + // Invalid JSON, return null + } + + return null; + } + + function parseZapReceipt(event: NostrEvent): { + amount: string; + senderPubkey?: string; + recipientPubkey?: string; + eventId?: string; + comment?: string; + } { + const pTag = event.tags.find(t => t[0] === 'p')?.[1]; // Recipient + const PTag = event.tags.find(t => t[0] === 'P')?.[1]; // Sender + const eTag = event.tags.find(t => t[0] === 'e')?.[1]; // Event being zapped + const bolt11Tag = event.tags.find(t => t[0] === 'bolt11')?.[1]; + const descriptionTag = event.tags.find(t => t[0] === 'description')?.[1]; + + // Parse amount from zap request in description tag (more reliable than bolt11) + let amount = 'Zap'; + if (descriptionTag) { + try { + const zapRequest = JSON.parse(descriptionTag); + const amountTag = zapRequest.tags?.find((t: string[]) => t[0] === 'amount'); + if (amountTag && amountTag[1]) { + const millisats = parseInt(amountTag[1]); + const sats = Math.round(millisats / 1000); + if (sats > 0) { + amount = `${sats} sats`; + } + } + } catch { + // Invalid JSON, try parsing from bolt11 as fallback + if (bolt11Tag) { + // Extract amount from bolt11 (format: lnbc{amount}{unit}) + // This is a simplified parser - bolt11 format is complex + const match = bolt11Tag.match(/lnbc(\d+)([munp])?/); + if (match) { + const num = parseInt(match[1]); + const unit = match[2] || ''; + let sats = num; + if (unit === 'm') sats = num / 1000; + else if (unit === 'u') sats = num / 1000000; + else if (unit === 'n') sats = num / 1000000000; + else if (unit === 'p') sats = num / 1000000000000; + if (sats > 0) { + amount = `${Math.round(sats)} sats`; + } + } + } + } + } + + // Parse comment from zap request in description tag + let comment: string | undefined; + if (descriptionTag) { + try { + const zapRequest = JSON.parse(descriptionTag); + if (zapRequest.content && zapRequest.content.trim()) { + comment = zapRequest.content.trim(); + } + } catch { + // Invalid JSON, ignore + } + } + + return { + amount, + senderPubkey: PTag, + recipientPubkey: pTag, + eventId: eTag, + comment + }; + } + + function startReply(event: NostrEvent) { + if (!viewerPubkeyHex) { + alert('Please connect your NIP-07 extension to reply'); + return; + } + replyingToEvent = event; + replyContent = ''; + showReplyDialog = true; + } + + async function sendReply() { + if (!replyingToEvent || !replyContent.trim() || !viewerPubkeyHex || sendingReply) return; + + sendingReply = true; + try { + let eventTemplate: Omit; + let targetRelays: string[]; + + if (replyingToEvent.kind === KIND.PUBLIC_MESSAGE) { + // Kind 24 reply (NIP-24) - use q-tag, no e/p/a tags + // Get relays from message recipients or use recipient's relays + const recipients = replyingToEvent.tags.filter(t => t[0] === 'p' && t[1]).map(t => t[1]); + const relayHints = replyingToEvent.tags.filter(t => t[0] === 'p' && t[2]).map(t => t[2]).filter(Boolean); + + // Use relay hints from p tags if available, otherwise fetch recipient's relays + if (relayHints.length > 0) { + targetRelays = [...new Set(relayHints)]; + } else if (recipients.length > 0) { + // Fetch relays from the first recipient (or use all recipients' relays) + const { getUserRelays } = await import('$lib/services/nostr/user-relays.js'); + const allSearchRelays = [...new Set([...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS])]; + const fullRelayClient = new NostrClient(allSearchRelays); + + // Get relays from all recipients and combine + const allRecipientRelays: string[] = []; + for (const recipient of recipients) { + const { outbox, inbox } = await getUserRelays(recipient, fullRelayClient); + allRecipientRelays.push(...outbox, ...inbox); + } + + targetRelays = combineRelays([...new Set(allRecipientRelays)], DEFAULT_NOSTR_RELAYS); + } else { + // Fallback to default relays + const { getUserRelays } = await import('$lib/services/nostr/user-relays.js'); + const allSearchRelays = [...new Set([...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS])]; + const fullRelayClient = new NostrClient(allSearchRelays); + const { outbox, inbox } = await getUserRelays(viewerPubkeyHex, fullRelayClient); + targetRelays = combineRelays(outbox.length > 0 ? outbox : inbox, DEFAULT_NOSTR_RELAYS); + } + + // Get relay hint from q-tag if available, otherwise use first target relay + const qTag = replyingToEvent.tags.find(t => t[0] === 'q'); + const relayHint = qTag?.[2] || targetRelays[0] || ''; + + eventTemplate = { + kind: KIND.PUBLIC_MESSAGE, + pubkey: viewerPubkeyHex, + created_at: Math.floor(Date.now() / 1000), + content: replyContent.trim(), + tags: [ + ['q', replyingToEvent.id, relayHint, replyingToEvent.pubkey], + // Add p tags for recipients (from the original message) + ...recipients.map(p => ['p', p, relayHint]) + ] + }; + } else if (replyingToEvent.kind === 1) { + const { getUserRelays } = await import('$lib/services/nostr/user-relays.js'); + const allSearchRelays = [...new Set([...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS])]; + const fullRelayClient = new NostrClient(allSearchRelays); + const { outbox, inbox } = await getUserRelays(viewerPubkeyHex, fullRelayClient); + targetRelays = combineRelays(outbox.length > 0 ? outbox : inbox, DEFAULT_NOSTR_RELAYS); + // Kind 1 reply (NIP-10) + const rootETag = replyingToEvent.tags.find(t => t[0] === 'e' && t[3] === 'root'); + const replyETag = replyingToEvent.tags.find(t => t[0] === 'e' && t[3] === 'reply'); + + // Determine root ID: use root tag if present, otherwise the event itself is the root + const rootId = rootETag?.[1] || replyingToEvent.id; + // Check if we're replying directly to the root (event has no reply tag) + const isDirectReplyToRoot = !replyETag; + + // Get all p tags from the event being replied to + const pTags = replyingToEvent.tags.filter(t => t[0] === 'p').map(t => t[1]); + // Add the author of the event being replied to if not already present + if (!pTags.includes(replyingToEvent.pubkey)) { + pTags.push(replyingToEvent.pubkey); + } + + const tags: string[][] = [ + ['e', rootId, '', 'root'] + ]; + + // Only add reply marker if not replying directly to root (NIP-10: for top-level replies, only root marker) + if (!isDirectReplyToRoot) { + tags.push(['e', replyingToEvent.id, '', 'reply']); + } + + tags.push(...pTags.map(p => ['p', p])); + + eventTemplate = { + kind: 1, + pubkey: viewerPubkeyHex, + created_at: Math.floor(Date.now() / 1000), + content: replyContent.trim(), + tags + }; + } else { + // Kind 1111 reply (NIP-22) + const { getUserRelays } = await import('$lib/services/nostr/user-relays.js'); + const allSearchRelays = [...new Set([...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS])]; + const fullRelayClient = new NostrClient(allSearchRelays); + const { outbox, inbox } = await getUserRelays(viewerPubkeyHex, fullRelayClient); + targetRelays = combineRelays(outbox.length > 0 ? outbox : inbox, DEFAULT_NOSTR_RELAYS); + + // Find root event info + const rootETag = replyingToEvent.tags.find(t => t[0] === 'E'); + const rootKTag = replyingToEvent.tags.find(t => t[0] === 'K'); + const rootPTag = replyingToEvent.tags.find(t => t[0] === 'P'); + const rootATag = replyingToEvent.tags.find(t => t[0] === 'A'); + + // Use the event being replied to as root if no root tags found + const rootEventId = rootETag?.[1] || replyingToEvent.id; + const rootEventKind = rootKTag ? parseInt(rootKTag[1]) : replyingToEvent.kind; + const rootPubkey = rootPTag?.[1] || replyingToEvent.pubkey; + const rootRelay = rootETag?.[2] || rootPTag?.[2] || ''; + + const tags: string[][] = [ + ['E', rootEventId, rootRelay, rootPubkey], + ['K', rootEventKind.toString()], + ['P', rootPubkey, rootRelay] + ]; + + if (rootATag) { + tags.push(['A', rootATag[1], rootATag[2] || '']); + } + + // Parent is the event being replied to + tags.push( + ['e', replyingToEvent.id, rootRelay, replyingToEvent.pubkey], + ['k', replyingToEvent.kind.toString()], + ['p', replyingToEvent.pubkey, rootRelay] + ); + + eventTemplate = { + kind: KIND.COMMENT, + pubkey: viewerPubkeyHex, + created_at: Math.floor(Date.now() / 1000), + content: replyContent.trim(), + tags + }; + } + + const signedEvent = await signEventWithNIP07(eventTemplate); + + // Publish to relays + await nostrClient.publishEvent(signedEvent, targetRelays); + + // Store the event kind before clearing + const wasMessageReply = replyingToEvent.kind === KIND.PUBLIC_MESSAGE; + + showReplyDialog = false; + replyingToEvent = null; + replyContent = ''; + + // Reload to show the new reply + if (wasMessageReply) { + // Reload messages if replying to a message + messagesLoaded = false; + await loadMessages(); + } else { + // Reload activity for other event types + activityLoaded = false; + await loadActivity(); + } + } catch (err) { + console.error('Failed to send reply:', err); + alert(`Failed to send reply: ${err instanceof Error ? err.message : String(err)}`); + } finally { + sendingReply = false; + } } function getEventLink(event: NostrEvent): string { @@ -1093,7 +1735,29 @@ i * {#each messages as message} {@const isFromViewer = viewerPubkeyHex !== null && message.pubkey === viewerPubkeyHex} {@const isToViewer = viewerPubkeyHex !== null && getMessageRecipients(message).includes(viewerPubkeyHex)} + {@const qTags = message.tags.filter(t => t[0] === 'q')}
+ {#if qTags.length > 0} + {#each qTags as qTag} + {@const quotedEventId = qTag[1]} + {@const relayHint = qTag[2] || ''} + {@const quotedEvent = getQuotedEvent(quotedEventId)} + {#if quotedEvent} +
+
+ + {formatMessageTime(quotedEvent.created_at)} +
+
{quotedEvent.content || '(No content)'}
+
+ {:else if quotedEventId} + +
+
Quoted event {quotedEventId.slice(0, 8)}...
+
+ {/if} + {/each} + {/if}
+
+ {#if true} + {@const parts = processContentWithNostrLinks(message.content)} + {#each parts as part} + {#if part.type === 'text'} + {part.value} + {:else if part.type === 'event' && part.event} + + {:else if part.type === 'profile' && part.pubkey} + + {:else} + {part.value} + {/if} + {/each} + {/if}
-
{message.content}
{/each}
@@ -1131,7 +1840,7 @@ i * {:else}
{#each activityEvents as event} -
+
{#if event.kind === 7} {@const reaction = event.content?.trim() || '+'} @@ -1142,8 +1851,101 @@ i * {eTag ? `Reacted to event ${eTag.slice(0, 8)}...` : 'Reacted'}
+ {:else if event.kind === KIND.ZAP_RECEIPT} + {@const zapData = parseZapReceipt(event)} +
+
+
+
{zapData.amount}
+
+ {#if zapData.senderPubkey} + From + {/if} + {#if zapData.recipientPubkey && zapData.recipientPubkey !== profileOwnerPubkeyHex} + To + {/if} + {#if zapData.eventId} + on event {zapData.eventId.slice(0, 8)}... + {/if} + {#if zapData.comment} +
{zapData.comment}
+ {/if} +
+
+
+ {:else if event.kind === KIND.REPOST} + {@const embeddedEvent = parseRepost(event)} + {#if embeddedEvent} +
+
+ Reposted +
+
+
+ + {formatMessageTime(embeddedEvent.created_at)} +
+
{embeddedEvent.content || '(No content)'}
+
+
+ {:else} + {@const content = getEventContext(event)} + {@const parts = processContentWithNostrLinks(content)} +
+ {#each parts as part} + {#if part.type === 'text'} + {part.value} + {:else if part.type === 'event' && part.event} + + {:else if part.type === 'profile' && part.pubkey} + + {:else} + {part.value} + {/if} + {/each} +
+ {/if} {:else} -

{getEventContext(event)}

+ {@const eTag = event.tags.find(t => t[0] === 'e' && t[1])?.[1]} + {@const aTag = event.tags.find(t => t[0] === 'a' && t[1])?.[1]} + {@const referencedEvent = getReferencedEvent(eTag, aTag)} + {#if referencedEvent} +
+
+ + {formatMessageTime(referencedEvent.created_at)} +
+
{referencedEvent.content || getEventContext(referencedEvent)}
+
+ {:else} + {@const content = getEventContext(event)} + {@const parts = processContentWithNostrLinks(content)} +
+ {#each parts as part} + {#if part.type === 'text'} + {part.value} + {:else if part.type === 'event' && part.event} + + {:else if part.type === 'profile' && part.pubkey} + + {:else} + {part.value} + {/if} + {/each} +
+ {/if} {/if}
- - View Event - +
+ {#if viewerPubkeyHex} + + {/if} + + View + +
{/each} @@ -1219,6 +2035,63 @@ i * {/if} + +{#if showReplyDialog && replyingToEvent} + +{/if} +