From 897f5df07ac67b5e58203d23e33bfd9c257b1d45 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 3 Feb 2026 16:17:54 +0100 Subject: [PATCH] bug-fixes --- public/healthz.json | 4 +- .../content/MarkdownRenderer.svelte | 59 ++- .../components/content/QuotedContext.svelte | 42 +- .../components/content/ReplyContext.svelte | 42 +- src/lib/components/layout/ProfileBadge.svelte | 3 + src/lib/modules/comments/CommentForm.svelte | 81 +++- src/lib/modules/comments/CommentThread.svelte | 378 +++++++++--------- src/lib/modules/feed/FeedPage.svelte | 89 +++-- src/lib/modules/feed/FeedPost.svelte | 138 ++++++- src/lib/modules/feed/ThreadDrawer.svelte | 332 +-------------- .../reactions/FeedReactionButtons.svelte | 307 ++++++++------ .../modules/reactions/ReactionButtons.svelte | 6 +- src/lib/modules/threads/ThreadView.svelte | 38 +- src/lib/modules/zaps/ZapButton.svelte | 3 + src/lib/modules/zaps/ZapReceipt.svelte | 2 + src/lib/services/nostr/nip30-emoji.ts | 4 +- src/lib/services/security/sanitizer.ts | 8 +- 17 files changed, 747 insertions(+), 789 deletions(-) diff --git a/public/healthz.json b/public/healthz.json index 72d0ad1..7ed82e5 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.1.0", - "buildTime": "2026-02-03T14:14:13.893Z", + "buildTime": "2026-02-03T14:56:41.652Z", "gitCommit": "unknown", - "timestamp": 1770128053893 + "timestamp": 1770130601652 } \ No newline at end of file diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index 061932a..101c7ec 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -34,9 +34,10 @@ const pubkey = el.getAttribute('data-pubkey'); const placeholder = el.getAttribute('data-placeholder'); if (pubkey && placeholder && profileBadges.has(placeholder)) { - // Clear the element and mount component - el.innerHTML = ''; - mountComponent(el as HTMLElement, ProfileBadge as any, { pubkey }); + // Don't clear if already mounted + if (el.children.length === 0) { + mountComponent(el as HTMLElement, ProfileBadge as any, { pubkey }); + } } }); @@ -46,9 +47,10 @@ const eventId = el.getAttribute('data-event-id'); const placeholder = el.getAttribute('data-placeholder'); if (eventId && placeholder && embeddedEvents.has(placeholder)) { - // Clear the element and mount component - el.innerHTML = ''; - mountComponent(el as HTMLElement, EmbeddedEvent as any, { eventId }); + // Don't clear if already mounted + if (el.children.length === 0) { + mountComponent(el as HTMLElement, EmbeddedEvent as any, { eventId }); + } } }); }); @@ -118,8 +120,9 @@ const sortedLinks = [...links].sort((a, b) => b.start - a.start); for (const link of sortedLinks) { - // Use a unique placeholder that won't be processed by markdown - const placeholder = `\u200B\u200B\u200BNIP21_LINK_${offset}\u200B\u200B\u200B`; + // Use a special marker that will be replaced after markdown parsing + // Use a format that markdown won't process: a code-like structure + const placeholder = `\`NIP21PLACEHOLDER${offset}\``; const before = processed.slice(0, link.start); const after = processed.slice(link.end); processed = before + placeholder + after; @@ -145,7 +148,7 @@ const eventId = parsed.data; const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, eventId); - replacement = `
`; + replacement = ``; } else { const decoded: any = nip19.decode(parsed.data); if (decoded.type === 'npub' || decoded.type === 'nprofile') { @@ -158,7 +161,7 @@ // Use custom element that will be replaced with ProfileBadge component const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`; profileBadges.set(badgePlaceholder, pubkey); - replacement = `
`; + replacement = ``; } else { replacement = `${uri}`; } @@ -167,17 +170,17 @@ // Use custom element for embedded event const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, eventId); - replacement = `
`; + replacement = ``; } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { const eventId = String(decoded.data.id); const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, eventId); - replacement = `
`; + replacement = ``; } else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') { // For naddr, we'd need to fetch by kind+pubkey+d, but for now use the bech32 string const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, parsed.data); // Store the bech32 string - replacement = `
`; + replacement = ``; } else { replacement = `${uri}`; } @@ -197,7 +200,7 @@ if (pubkey) { const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`; profileBadges.set(badgePlaceholder, pubkey); - replacement = `
`; + replacement = ``; } else { replacement = `${uri}`; } @@ -209,13 +212,17 @@ } } - // Escape placeholder for regex replacement + // Replace placeholder - it will be in a tag after markdown parsing + const codePlaceholder = `${placeholder.replace(/`/g, '')}`; + finalHtml = finalHtml.replace(new RegExp(codePlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement); + // Also try without code tag (in case markdown didn't process it) const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement); } - // Clean up any remaining placeholders (fallback) - finalHtml = finalHtml.replace(/\u200B\u200B\u200BNIP21_LINK_\d+\u200B\u200B\u200B/g, ''); + // Clean up any remaining placeholders (fallback) - look for code tags with our placeholder + finalHtml = finalHtml.replace(/NIP21PLACEHOLDER\d+<\/code>/g, ''); + finalHtml = finalHtml.replace(/NIP21PLACEHOLDER\d+/g, ''); rendered = finalHtml; }); @@ -235,7 +242,7 @@ const eventId = parsed.data; const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, eventId); - replacement = `
`; + replacement = ``; } else { const decoded: any = nip19.decode(parsed.data); if (decoded.type === 'npub' || decoded.type === 'nprofile') { @@ -247,7 +254,7 @@ if (pubkey) { const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`; profileBadges.set(badgePlaceholder, pubkey); - replacement = `
`; + replacement = ``; } else { replacement = `${uri}`; } @@ -255,16 +262,16 @@ const eventId = String(decoded.data); const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, eventId); - replacement = `
`; + replacement = ``; } else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { const eventId = String(decoded.data.id); const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, eventId); - replacement = `
`; + replacement = ``; } else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') { const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`; embeddedEvents.set(eventPlaceholder, parsed.data); - replacement = `
`; + replacement = ``; } else { replacement = `${uri}`; } @@ -387,6 +394,8 @@ .markdown-content :global(img) { max-width: 100%; height: auto; + display: block; + margin: 0.5em 0; filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); } @@ -394,6 +403,12 @@ filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); } + .markdown-content :global(.nostr-profile-badge-placeholder), + .markdown-content :global(.nostr-embedded-event-placeholder) { + display: inline-block; + vertical-align: middle; + } + /* Style emojis in content */ .markdown-content :global(span[role="img"]), .markdown-content :global(.emoji) { diff --git a/src/lib/components/content/QuotedContext.svelte b/src/lib/components/content/QuotedContext.svelte index 42bed2c..10187f7 100644 --- a/src/lib/components/content/QuotedContext.svelte +++ b/src/lib/components/content/QuotedContext.svelte @@ -9,9 +9,10 @@ quotedEventId?: string; // Optional - used to load quoted event if not provided targetId?: string; // Optional ID to scroll to (defaults to quoted event ID) onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded + onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer } - let { quotedEvent: providedQuotedEvent, quotedEventId, targetId, onQuotedLoaded }: Props = $props(); + let { quotedEvent: providedQuotedEvent, quotedEventId, targetId, onQuotedLoaded, onOpenEvent }: Props = $props(); let loadedQuotedEvent = $state(null); let loadingQuoted = $state(false); @@ -50,8 +51,6 @@ if (onQuotedLoaded && typeof onQuotedLoaded === 'function') { onQuotedLoaded(loadedQuotedEvent); } - // After loading, try to scroll to it - setTimeout(() => scrollToQuoted(), 100); } } catch (error) { console.error('Error loading quoted event:', error); @@ -69,45 +68,10 @@ return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : ''); } - async function scrollToQuoted() { - const eventId = quotedEvent?.id || quotedEventId; - if (!eventId) return; - - // If quoted event not loaded yet, load it first - if (!quotedEvent && quotedEventId) { - await loadQuotedEvent(); - } - - const elementId = targetId || `event-${eventId}`; - let element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`); - - // If still not found, wait a bit for DOM to update - if (!element) { - await new Promise(resolve => setTimeout(resolve, 200)); - element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`); - } - - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'center' }); - element.classList.add('highlight-quoted'); - setTimeout(() => { - element?.classList.remove('highlight-quoted'); - }, 2000); - } - }
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - scrollToQuoted(); - } - }} + class="quoted-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light" > Quoting: {getQuotedPreview()} {#if loadingQuoted} diff --git a/src/lib/components/content/ReplyContext.svelte b/src/lib/components/content/ReplyContext.svelte index 5292f11..311011a 100644 --- a/src/lib/components/content/ReplyContext.svelte +++ b/src/lib/components/content/ReplyContext.svelte @@ -9,9 +9,10 @@ parentEventId?: string; // Optional - used to load parent if not provided targetId?: string; // Optional ID to scroll to (defaults to parent event ID) onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded + onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer } - let { parentEvent: providedParentEvent, parentEventId, targetId, onParentLoaded }: Props = $props(); + let { parentEvent: providedParentEvent, parentEventId, targetId, onParentLoaded, onOpenEvent }: Props = $props(); let loadedParentEvent = $state(null); let loadingParent = $state(false); @@ -50,8 +51,6 @@ if (onParentLoaded && typeof onParentLoaded === 'function') { onParentLoaded(loadedParentEvent); } - // After loading, try to scroll to it - setTimeout(() => scrollToParent(), 100); } } catch (error) { console.error('Error loading parent event:', error); @@ -69,45 +68,10 @@ return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : ''); } - async function scrollToParent() { - const eventId = parentEvent?.id || parentEventId; - if (!eventId) return; - - // If parent not loaded yet, load it first - if (!parentEvent && parentEventId) { - await loadParentEvent(); - } - - const elementId = targetId || `event-${eventId}`; - let element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`); - - // If still not found, wait a bit for DOM to update - if (!element) { - await new Promise(resolve => setTimeout(resolve, 200)); - element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`); - } - - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'center' }); - element.classList.add('highlight-parent'); - setTimeout(() => { - element?.classList.remove('highlight-parent'); - }, 2000); - } - }
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - scrollToParent(); - } - }} + class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light" > Replying to: {getParentPreview()} {#if loadingParent} diff --git a/src/lib/components/layout/ProfileBadge.svelte b/src/lib/components/layout/ProfileBadge.svelte index efe612e..8e2ac60 100644 --- a/src/lib/components/layout/ProfileBadge.svelte +++ b/src/lib/components/layout/ProfileBadge.svelte @@ -133,10 +133,13 @@ text-decoration: none; color: inherit; max-width: 100%; + filter: grayscale(100%) opacity(0.7); + transition: filter 0.2s; } .profile-badge:hover { text-decoration: underline; + filter: grayscale(100%) opacity(0.9); } .profile-picture { diff --git a/src/lib/modules/comments/CommentForm.svelte b/src/lib/modules/comments/CommentForm.svelte index 899545a..df2ff22 100644 --- a/src/lib/modules/comments/CommentForm.svelte +++ b/src/lib/modules/comments/CommentForm.svelte @@ -5,18 +5,45 @@ import type { NostrEvent } from '../../types/nostr.js'; interface Props { - threadId: string; // The kind 11 thread event ID - parentEvent?: NostrEvent; // If replying to a comment + threadId: string; // The root event ID + rootEvent?: NostrEvent; // The root event (to determine reply kind) + parentEvent?: NostrEvent; // If replying to a comment/reply onPublished?: () => void; onCancel?: () => void; } - let { threadId, parentEvent, onPublished, onCancel }: Props = $props(); + let { threadId, rootEvent, parentEvent, onPublished, onCancel }: Props = $props(); let content = $state(''); let publishing = $state(false); let includeClientTag = $state(true); + /** + * Determine what kind of reply to create + * - Kind 1 events should get kind 1 replies + * - Everything else gets kind 1111 comments + */ + function getReplyKind(): number { + // If replying to a parent event, check its kind + if (parentEvent) { + // If parent is kind 1, reply with kind 1 + if (parentEvent.kind === 1) return 1; + // Everything else gets kind 1111 + return 1111; + } + + // If replying to root, check root kind + if (rootEvent) { + // If root is kind 1, reply with kind 1 + if (rootEvent.kind === 1) return 1; + // Everything else gets kind 1111 + return 1111; + } + + // Default to kind 1111 if we can't determine + return 1111; + } + async function publish() { if (!sessionManager.isLoggedIn()) { alert('Please log in to comment'); @@ -31,18 +58,40 @@ publishing = true; try { - const tags: string[][] = [ - ['K', '11'], // Kind of the event being commented on - ['E', threadId] // Event ID of the thread - ]; - - // If replying to a comment, add parent references - if (parentEvent) { - tags.push(['E', parentEvent.id]); // Parent comment event ID - tags.push(['e', parentEvent.id]); // Also add lowercase e tag for compatibility - tags.push(['p', parentEvent.pubkey]); // Parent comment author - tags.push(['P', parentEvent.pubkey]); // NIP-22 uppercase P tag - tags.push(['k', '1111']); // Kind of parent (comment) + const replyKind = getReplyKind(); + const tags: string[][] = []; + + if (replyKind === 1) { + // Kind 1 reply (NIP-10) + tags.push(['e', threadId]); // Root event + if (rootEvent) { + tags.push(['p', rootEvent.pubkey]); // Root author + } + + // If replying to a parent, add parent references + if (parentEvent) { + tags.push(['e', parentEvent.id, '', 'reply']); // Parent event with 'reply' marker + tags.push(['p', parentEvent.pubkey]); // Parent author + } + } else { + // Kind 1111 comment (NIP-22) + const rootKind = rootEvent?.kind || '1'; + tags.push(['K', String(rootKind)]); // Root kind + tags.push(['E', threadId]); // Root event ID (uppercase for NIP-22) + if (rootEvent) { + tags.push(['P', rootEvent.pubkey]); // Root author (uppercase P) + } + + // If replying to a parent, add parent references + if (parentEvent) { + const parentKind = parentEvent.kind; + tags.push(['e', parentEvent.id]); // Parent event ID (lowercase for parent) + tags.push(['k', String(parentKind)]); // Parent kind (lowercase k) + tags.push(['p', parentEvent.pubkey]); // Parent author (lowercase p) + // Also add uppercase for compatibility + tags.push(['E', parentEvent.id]); + tags.push(['P', parentEvent.pubkey]); + } } if (includeClientTag) { @@ -50,7 +99,7 @@ } const event: Omit = { - kind: 1111, + kind: replyKind, pubkey: sessionManager.getCurrentPubkey()!, created_at: Math.floor(Date.now() / 1000), tags, diff --git a/src/lib/modules/comments/CommentThread.svelte b/src/lib/modules/comments/CommentThread.svelte index b7bdf98..05940b7 100644 --- a/src/lib/modules/comments/CommentThread.svelte +++ b/src/lib/modules/comments/CommentThread.svelte @@ -9,55 +9,149 @@ import type { NostrEvent } from '../../types/nostr.js'; interface Props { - threadId: string; // The event ID - event?: NostrEvent; // The event itself (optional, used to determine reply types) + threadId: string; // The event ID of the root event + event?: NostrEvent; // The root event itself (optional, used to determine reply types) } let { threadId, event }: Props = $props(); - let comments = $state([]); - let kind1Replies = $state([]); - let yakBacks = $state([]); - let zapReceipts = $state([]); + let comments = $state([]); // kind 1111 + let kind1Replies = $state([]); // kind 1 replies (should only be for kind 1 events, but some apps use them for everything) + let yakBacks = $state([]); // kind 1244 (voice replies) + let zapReceipts = $state([]); // kind 9735 (zap receipts) let loading = $state(true); let replyingTo = $state(null); const isKind1 = $derived(event?.kind === 1); + const rootKind = $derived(event?.kind || null); onMount(async () => { await nostrClient.initialize(); - loadComments(); }); + // Reload comments when threadId or event changes + $effect(() => { + if (threadId) { + loadComments(); + } + }); + + /** + * Get the parent event ID from a reply event + * For kind 1111: checks both E/e and A/a tags (NIP-22) + * For kind 1: checks e tag (NIP-10) + * For kind 1244: checks E/e and A/a tags (follows NIP-22) + * For kind 9735: checks e tag + */ + function getParentEventId(replyEvent: NostrEvent): string | null { + // For kind 1111, check both uppercase and lowercase E and A tags + if (replyEvent.kind === 1111) { + // Check uppercase E tag first (NIP-22 standard for root) + const eTag = replyEvent.tags.find((t) => t[0] === 'E'); + if (eTag && eTag[1]) { + // If it points to root, check lowercase e for parent + if (eTag[1] === threadId) { + const parentETag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] !== threadId); + if (parentETag && parentETag[1]) return parentETag[1]; + } else { + // E tag points to parent (non-standard but some clients do this) + return eTag[1]; + } + } + // Check lowercase e tag + const lowerETag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] !== threadId && t[1] !== replyEvent.id); + if (lowerETag && lowerETag[1]) return lowerETag[1]; + + // Check uppercase A tag (NIP-22 for addressable events) + const aTag = replyEvent.tags.find((t) => t[0] === 'A'); + if (aTag && aTag[1]) { + // If it points to root, check lowercase a for parent + const parentATag = replyEvent.tags.find((t) => t[0] === 'a' && t[1] !== aTag[1]); + if (parentATag && parentATag[1]) { + // Need to find event by address - for now, check if we have it + // This is complex, so we'll handle it in the parent lookup + } + } + // Check lowercase a tag + const lowerATag = replyEvent.tags.find((t) => t[0] === 'a'); + if (lowerATag && lowerATag[1]) { + // Try to find event with matching address + // For now, we'll handle this by checking all events + } + } + + // For kind 1, 1244, 9735: check e tag + if (replyEvent.kind === 1 || replyEvent.kind === 1244 || replyEvent.kind === 9735) { + const eTag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] !== replyEvent.id); + if (eTag && eTag[1]) return eTag[1]; + } + + return null; + } + + /** + * Check if a reply event references the root thread + * For kind 1111: checks both E/e and A/a tags (NIP-22) + * For other kinds: checks e tag + */ + function referencesRoot(replyEvent: NostrEvent): boolean { + if (replyEvent.kind === 1111) { + // Check uppercase E tag (NIP-22 standard for root) + const eTag = replyEvent.tags.find((t) => t[0] === 'E'); + if (eTag && eTag[1] === threadId) return true; + + // Check lowercase e tag (fallback) + const lowerETag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] === threadId); + if (lowerETag) return true; + + // Check A and a tags for addressable events + // If root event has an address (a-tag), check if reply references it + if (event) { + const rootATag = event.tags.find((t) => t[0] === 'a'); + if (rootATag && rootATag[1]) { + const replyATag = replyEvent.tags.find((t) => t[0] === 'A'); + if (replyATag && replyATag[1] === rootATag[1]) return true; + const replyLowerATag = replyEvent.tags.find((t) => t[0] === 'a' && t[1] === rootATag[1]); + if (replyLowerATag) return true; + } + } + + // If no direct reference found, check if parent is root + const parentId = getParentEventId(replyEvent); + return parentId === null || parentId === threadId; + } + + // For other kinds, check e tag + const eTag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] === threadId); + return !!eTag; + } + async function loadComments() { loading = true; try { - const config = nostrClient.getConfig(); const relays = relayManager.getCommentReadRelays(); const feedRelays = relayManager.getFeedReadRelays(); const allRelays = [...new Set([...relays, ...feedRelays])]; - const replyFilters: any[] = [ - { kinds: [9735], '#e': [threadId] }, // Zap receipts - { kinds: [1244], '#e': [threadId] }, // Yak backs (voice replies) - ]; + const replyFilters: any[] = []; - // For kind 1 events, also fetch kind 1 replies - if (isKind1) { - replyFilters.push({ kinds: [1], '#e': [threadId] }); - } + // Always fetch kind 1111 comments - check both e and E tags, and a and A tags + replyFilters.push( + { kinds: [1111], '#e': [threadId] }, // Lowercase e tag + { kinds: [1111], '#E': [threadId] }, // Uppercase E tag (NIP-22) + { kinds: [1111], '#a': [threadId] }, // Lowercase a tag (some clients use wrong tags) + { kinds: [1111], '#A': [threadId] } // Uppercase A tag (NIP-22 for addressable events) + ); - // For all events, fetch kind 1111 comments - // For kind 11 threads, use #E and #K tags (NIP-22) - // For other events, use #e tag - if (event?.kind === 11) { - replyFilters.push( - { kinds: [1111], '#E': [threadId], '#K': ['11'] }, // NIP-22 standard (uppercase) - { kinds: [1111], '#e': [threadId] } // Fallback (lowercase) - ); - } else { - replyFilters.push({ kinds: [1111], '#e': [threadId] }); - } + // For kind 1 events, fetch kind 1 replies + // Also fetch kind 1 replies for any event (some apps use kind 1 for everything) + replyFilters.push({ kinds: [1], '#e': [threadId] }); + + // Fetch yak backs (kind 1244) - voice replies + replyFilters.push({ kinds: [1244], '#e': [threadId] }); + + // Fetch zap receipts (kind 9735) + replyFilters.push({ kinds: [9735], '#e': [threadId] }); const allReplies = await nostrClient.fetchEvents( replyFilters, @@ -65,17 +159,18 @@ { useCache: true, cacheResults: true } ); + // Filter to only replies that reference the root + const rootReplies = allReplies.filter(reply => referencesRoot(reply)); + // Separate by type - comments = allReplies.filter(e => e.kind === 1111); - kind1Replies = allReplies.filter(e => e.kind === 1); - yakBacks = allReplies.filter(e => e.kind === 1244); - zapReceipts = allReplies.filter(e => e.kind === 9735); + comments = rootReplies.filter(e => e.kind === 1111); + kind1Replies = rootReplies.filter(e => e.kind === 1); + yakBacks = rootReplies.filter(e => e.kind === 1244); + zapReceipts = rootReplies.filter(e => e.kind === 9735); // Recursively fetch all nested replies await fetchNestedReplies(); - // Fetch zap receipts that reference this thread or any comment/reply - await fetchZapReceipts(); } catch (error) { console.error('Error loading comments:', error); } finally { @@ -89,49 +184,43 @@ const allRelays = [...new Set([...relays, ...feedRelays])]; let hasNewReplies = true; let iterations = 0; - const maxIterations = 10; // Prevent infinite loops + const maxIterations = 10; - // Keep fetching until we have all nested replies while (hasNewReplies && iterations < maxIterations) { iterations++; hasNewReplies = false; const allReplyIds = new Set([ ...comments.map(c => c.id), ...kind1Replies.map(r => r.id), - ...yakBacks.map(y => y.id) + ...yakBacks.map(y => y.id), + ...zapReceipts.map(z => z.id) ]); if (allReplyIds.size > 0) { const nestedFilters: any[] = [ - { kinds: [9735], '#e': Array.from(allReplyIds) }, // Zap receipts - { kinds: [1244], '#e': Array.from(allReplyIds) }, // Yak backs + // Fetch nested kind 1111 comments - check both e/E and a/A tags + { kinds: [1111], '#e': Array.from(allReplyIds) }, + { kinds: [1111], '#E': Array.from(allReplyIds) }, + { kinds: [1111], '#a': Array.from(allReplyIds) }, + { kinds: [1111], '#A': Array.from(allReplyIds) }, + // Fetch nested kind 1 replies + { kinds: [1], '#e': Array.from(allReplyIds) }, + // Fetch nested yak backs + { kinds: [1244], '#e': Array.from(allReplyIds) }, + // Fetch nested zap receipts + { kinds: [9735], '#e': Array.from(allReplyIds) } ]; - - // For kind 1 events, also fetch nested kind 1 replies - if (isKind1) { - nestedFilters.push({ kinds: [1], '#e': Array.from(allReplyIds) }); - } - - // Fetch nested comments - if (event?.kind === 11) { - nestedFilters.push( - { kinds: [1111], '#E': Array.from(allReplyIds), '#K': ['11'] }, - { kinds: [1111], '#e': Array.from(allReplyIds) } - ); - } else { - nestedFilters.push({ kinds: [1111], '#e': Array.from(allReplyIds) }); - } const nestedReplies = await nostrClient.fetchEvents( nestedFilters, allRelays, - { useCache: true, cacheResults: true } - ); - + { useCache: true, cacheResults: true } + ); + // Add new replies by type for (const reply of nestedReplies) { if (reply.kind === 1111 && !comments.some(c => c.id === reply.id)) { - comments.push(reply); + comments.push(reply); hasNewReplies = true; } else if (reply.kind === 1 && !kind1Replies.some(r => r.id === reply.id)) { kind1Replies.push(reply); @@ -139,114 +228,42 @@ } else if (reply.kind === 1244 && !yakBacks.some(y => y.id === reply.id)) { yakBacks.push(reply); hasNewReplies = true; + } else if (reply.kind === 9735 && !zapReceipts.some(z => z.id === reply.id)) { + zapReceipts.push(reply); + hasNewReplies = true; + } } } - } } } - - async function fetchZapReceipts() { - const config = nostrClient.getConfig(); - const relays = relayManager.getCommentReadRelays(); - const feedRelays = relayManager.getFeedReadRelays(); - const allRelays = [...new Set([...relays, ...feedRelays])]; + /** + * Get parent event from any of our loaded events + */ + function getParentEvent(replyEvent: NostrEvent): NostrEvent | undefined { + const parentId = getParentEventId(replyEvent); + if (!parentId) return undefined; - // Keep fetching until we have all zaps - let previousCount = -1; - while (zapReceipts.length !== previousCount) { - previousCount = zapReceipts.length; - const allEventIds = new Set([ - threadId, - ...comments.map(c => c.id), - ...kind1Replies.map(r => r.id), - ...yakBacks.map(y => y.id), - ...zapReceipts.map(z => z.id) - ]); - - // Fetch zap receipts that reference thread or any comment/reply/yak/zap - const zapFilters = [ - { - kinds: [9735], - '#e': Array.from(allEventIds) // Zap receipts for thread and all replies - } - ]; - - const zapEvents = await nostrClient.fetchEvents( - zapFilters, - allRelays, - { useCache: true, cacheResults: true } - ); - - const validZaps = zapEvents.filter(receipt => { - // Filter by threshold - const amountTag = receipt.tags.find((t) => t[0] === 'amount'); - if (amountTag && amountTag[1]) { - const amount = parseInt(amountTag[1], 10); - return !isNaN(amount) && amount >= config.zapThreshold; - } - return false; - }); - - // Add new zap receipts - const existingZapIds = new Set(zapReceipts.map(z => z.id)); - for (const zap of validZaps) { - if (!existingZapIds.has(zap.id)) { - zapReceipts.push(zap); - } - } - - // Check if any zaps reference events we don't have - const missingEventIds = new Set(); - for (const zap of validZaps) { - const eTag = zap.tags.find((t) => t[0] === 'e'); - if (eTag && eTag[1] && eTag[1] !== threadId) { - const exists = comments.some(c => c.id === eTag[1]) - || kind1Replies.some(r => r.id === eTag[1]) - || yakBacks.some(y => y.id === eTag[1]); - if (!exists) { - missingEventIds.add(eTag[1]); - } - } - } - - // Fetch missing events (could be comments, replies, or yak backs) - if (missingEventIds.size > 0) { - const missingEvents = await nostrClient.fetchEvents( - [ - { kinds: [1111], ids: Array.from(missingEventIds) }, - { kinds: [1], ids: Array.from(missingEventIds) }, - { kinds: [1244], ids: Array.from(missingEventIds) } - ], - allRelays, - { useCache: true, cacheResults: true } - ); - - for (const event of missingEvents) { - if (event.kind === 1111 && !comments.some(c => c.id === event.id)) { - comments.push(event); - } else if (event.kind === 1 && !kind1Replies.some(r => r.id === event.id)) { - kind1Replies.push(event); - } else if (event.kind === 1244 && !yakBacks.some(y => y.id === event.id)) { - yakBacks.push(event); - } - } - - // Fetch nested replies to newly found events - await fetchNestedReplies(); - } - } + // Check if parent is the root event + if (parentId === threadId) return event || undefined; + + // Find parent in loaded events + return comments.find(c => c.id === parentId) + || kind1Replies.find(r => r.id === parentId) + || yakBacks.find(y => y.id === parentId) + || zapReceipts.find(z => z.id === parentId); } - + /** + * Sort thread items with proper nesting + */ function sortThreadItems(items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }>): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> { - // Build thread structure similar to feed const eventMap = new Map(); const replyMap = new Map(); // parentId -> childIds[] const rootItems: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = []; const allEventIds = new Set(); - // First pass: build event map and collect all event IDs + // First pass: build event map for (const item of items) { eventMap.set(item.event.id, item); allEventIds.add(item.event.id); @@ -254,24 +271,16 @@ // Second pass: determine parent-child relationships for (const item of items) { - // Check if this is a reply - check both uppercase E (NIP-22) and lowercase e tags - const eTag = item.event.tags.find((t) => t[0] === 'E') || item.event.tags.find((t) => t[0] === 'e' && t[1] !== item.event.id); - const parentId = eTag?.[1]; + const parentId = getParentEventId(item.event); - if (parentId) { - // Check if parent is the thread or another reply we have - if (parentId === threadId || allEventIds.has(parentId)) { + if (parentId && (parentId === threadId || allEventIds.has(parentId))) { // This is a reply if (!replyMap.has(parentId)) { replyMap.set(parentId, []); } replyMap.get(parentId)!.push(item.event.id); } else { - // Parent not found - treat as root item (might be a missing parent) - rootItems.push(item); - } - } else { - // No parent tag - this is a root item (direct reply to thread) + // No parent or parent not found - treat as root item rootItems.push(item); } } @@ -291,7 +300,7 @@ const replyItems = replies .map(id => eventMap.get(id)) .filter((item): item is { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' } => item !== undefined) - .sort((a, b) => a.event.created_at - b.event.created_at); // Sort replies chronologically + .sort((a, b) => a.event.created_at - b.event.created_at); for (const reply of replyItems) { addThread(reply); @@ -317,31 +326,31 @@ return sortThreadItems(items); } - function getParentEvent(event: NostrEvent): NostrEvent | undefined { - // NIP-22: E tag (uppercase) points to parent event, or lowercase e tag - const eTag = event.tags.find((t) => t[0] === 'E') || event.tags.find((t) => t[0] === 'e' && t[1] !== event.id); - if (eTag && eTag[1]) { - // Find parent in comments, replies, yak backs, or zap receipts - const parent = comments.find((c) => c.id === eTag[1]) - || kind1Replies.find((r) => r.id === eTag[1]) - || yakBacks.find((y) => y.id === eTag[1]) - || zapReceipts.find((z) => z.id === eTag[1]); - if (parent) return parent; - - // If parent not found, it might be the thread itself - return undefined; - } - return undefined; - } - - function handleReply(comment: NostrEvent) { - replyingTo = comment; + function handleReply(replyEvent: NostrEvent) { + replyingTo = replyEvent; } function handleCommentPublished() { replyingTo = null; loadComments(); } + + /** + * Determine what kind of reply is allowed for a given event + */ + function getAllowedReplyKind(targetEvent: NostrEvent | null): number { + if (!targetEvent) { + // If replying to root, check root kind + if (isKind1) return 1; + return 1111; + } + + // If target is kind 1, allow kind 1 reply + if (targetEvent.kind === 1) return 1; + + // Everything else gets kind 1111 + return 1111; + }
@@ -372,6 +381,7 @@
{:else if item.type === 'zap'} + (replyingTo = null)} @@ -394,7 +405,8 @@ {:else}
diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte index d45a3c7..bc2ce4c 100644 --- a/src/lib/modules/feed/FeedPage.svelte +++ b/src/lib/modules/feed/FeedPage.svelte @@ -2,8 +2,9 @@ import { nostrClient } from '../../services/nostr/nostr-client.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; import FeedPost from './FeedPost.svelte'; + import ThreadDrawer from './ThreadDrawer.svelte'; import type { NostrEvent } from '../../types/nostr.js'; - import { onMount } from 'svelte'; + import { onMount, tick } from 'svelte'; let posts = $state([]); let loading = $state(true); @@ -11,31 +12,37 @@ let hasMore = $state(true); let oldestTimestamp = $state(null); + // Drawer state for viewing parent/quoted events + let drawerOpen = $state(false); + let drawerEvent = $state(null); + // Debounce updates to prevent rapid re-renders let updateTimeout: ReturnType | null = null; let pendingUpdates: NostrEvent[] = []; + + function openDrawer(event: NostrEvent) { + drawerEvent = event; + drawerOpen = true; + } + + function closeDrawer() { + drawerOpen = false; + drawerEvent = null; + } - onMount(() => { - (async () => { - await nostrClient.initialize(); - await loadFeed(); - })(); - - // Set up intersection observer for infinite scroll - const observer = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && hasMore && !loadingMore) { - loadMore(); - } - }, { threshold: 0.1 }); + let sentinelElement = $state(null); + let observer: IntersectionObserver | null = null; - const sentinel = document.getElementById('feed-sentinel'); - if (sentinel) { - observer.observe(sentinel); - } + onMount(async () => { + await nostrClient.initialize(); + await loadFeed(); + }); + // Cleanup on unmount + $effect(() => { return () => { - if (sentinel) { - observer.unobserve(sentinel); + if (observer) { + observer.disconnect(); } if (updateTimeout) { clearTimeout(updateTimeout); @@ -43,6 +50,26 @@ }; }); + // Set up observer when sentinel element is available + $effect(() => { + if (sentinelElement && !loading && !observer) { + observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore && !loadingMore) { + loadMore(); + } + }, { threshold: 0.1 }); + + observer.observe(sentinelElement); + + return () => { + if (observer) { + observer.disconnect(); + observer = null; + } + }; + } + }); + async function loadFeed() { loading = true; try { @@ -119,9 +146,21 @@ if (oldest < (oldestTimestamp || Infinity)) { oldestTimestamp = oldest; } + hasMore = events.length >= 20; + } else if (events.length > 0) { + // All events were duplicates, but we got some results + // This might mean we've reached the end, or we need to adjust the timestamp + if (oldestTimestamp) { + // Try moving the timestamp forward slightly to avoid getting the same results + oldestTimestamp = oldestTimestamp - 1; + hasMore = events.length >= 20; + } else { + hasMore = false; + } + } else { + // No events returned at all + hasMore = false; } - - hasMore = events.length >= 20; } catch (error) { console.error('Error loading more:', error); } finally { @@ -168,11 +207,15 @@ {:else}
{#each posts as post (post.id)} - + {/each}
-
+ {#if drawerOpen && drawerEvent} + + {/if} + +
{#if loadingMore}

Loading more...

{:else if hasMore} diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index 19a3783..eac24d7 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -19,16 +19,18 @@ onReply?: (post: NostrEvent) => void; onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded + onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer previewMode?: boolean; // If true, show only title and first 150 chars of content } - let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, previewMode = false }: Props = $props(); + let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, onOpenEvent, previewMode = false }: Props = $props(); let loadedParentEvent = $state(null); let loadingParent = $state(false); let expanded = $state(false); let contentElement: HTMLElement | null = $state(null); let needsExpansion = $state(false); + let zapCount = $state(0); // Derive the effective parent event: prefer provided, fall back to loaded let parentEvent = $derived(providedParentEvent || loadedParentEvent); @@ -51,8 +53,43 @@ if (!providedParentEvent && !loadedParentEvent && isReply()) { await loadParentEvent(); } + // Load zap receipt count + await loadZapCount(); }); + async function loadZapCount() { + try { + const config = nostrClient.getConfig(); + const threshold = config.zapThreshold; + + const filters = [{ + kinds: [9735], + '#e': [post.id] + }]; + + const receipts = await nostrClient.fetchEvents( + filters, + [...config.defaultRelays], + { useCache: true, cacheResults: true } + ); + + // Filter by threshold and count + const validReceipts = receipts.filter((receipt) => { + const amountTag = receipt.tags.find((t) => t[0] === 'amount'); + if (amountTag && amountTag[1]) { + const amount = parseInt(amountTag[1], 10); + return !isNaN(amount) && amount >= threshold; + } + return false; + }); + + zapCount = validReceipts.length; + } catch (error) { + console.error('Error loading zap count:', error); + zapCount = 0; + } + } + function getRelativeTime(): string { const now = Math.floor(Date.now() / 1000); const diff = now - post.created_at; @@ -164,9 +201,65 @@ return plaintext.slice(0, 150) + (plaintext.length > 150 ? '...' : ''); } + function handlePostClick(e: MouseEvent) { + // Don't open drawer if clicking on interactive elements + const target = e.target as HTMLElement; + if ( + target.tagName === 'BUTTON' || + target.tagName === 'A' || + target.closest('button') || + target.closest('a') || + target.closest('.Feed-reaction-buttons') || + target.closest('.post-actions') + ) { + return; + } + + // Open drawer if onOpenEvent callback is provided + if (onOpenEvent) { + onOpenEvent(post); + } + } + + function handlePostKeydown(e: KeyboardEvent) { + // Only handle Enter and Space keys + if (e.key !== 'Enter' && e.key !== ' ') { + return; + } + + // Don't open drawer if focus is on interactive elements + const target = e.target as HTMLElement; + if ( + target.tagName === 'BUTTON' || + target.tagName === 'A' || + target.closest('button') || + target.closest('a') || + target.closest('.Feed-reaction-buttons') || + target.closest('.post-actions') + ) { + return; + } + + e.preventDefault(); + + // Open drawer if onOpenEvent callback is provided + if (onOpenEvent) { + onOpenEvent(post); + } + } + -
+
{#if previewMode}
@@ -199,6 +292,7 @@ parentEventId={getReplyEventId() || undefined} targetId={parentEvent ? `event-${parentEvent.id}` : undefined} onParentLoaded={onParentLoaded} + onOpenEvent={onOpenEvent} /> {/if} @@ -208,6 +302,7 @@ quotedEventId={getQuotedEventId() || undefined} targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined} onQuotedLoaded={onQuotedLoaded} + onOpenEvent={onOpenEvent} /> {/if} @@ -228,6 +323,12 @@
+ {#if zapCount > 0} + + + {zapCount} + + {/if} {#if onReply}