From b0253cc340bd1a30509ee8aa1a26215669547cef Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 6 Feb 2026 22:40:21 +0100 Subject: [PATCH] add the reply-to blurb back onto feed cards --- .../components/content/MetadataCard.svelte | 22 +- .../content/ReferencedEventPreview.svelte | 418 ++++++++++ src/lib/components/layout/Header.svelte | 5 +- src/lib/components/profile/ProfileMenu.svelte | 7 - src/lib/modules/comments/Comment.svelte | 24 +- .../modules/discussions/DiscussionCard.svelte | 17 + src/lib/modules/feed/FeedPage.svelte | 197 ++++- src/lib/modules/feed/FeedPost.svelte | 38 +- src/lib/modules/feed/HighlightCard.svelte | 21 +- src/lib/modules/feed/Reply.svelte | 20 +- src/lib/modules/feed/ZapReceiptReply.svelte | 19 +- src/lib/modules/profiles/ProfilePage.svelte | 119 ++- src/lib/modules/rss/RSSCommentForm.svelte | 671 +++++++++++++++ src/lib/services/event-links.ts | 46 ++ src/lib/types/kind-lookup.ts | 3 +- src/routes/bookmarks/+page.svelte | 136 +++- src/routes/highlights/+page.svelte | 769 ++++++++++++++++++ src/routes/lists/+page.svelte | 265 ++++++ src/routes/rss/+page.svelte | 42 + 19 files changed, 2774 insertions(+), 65 deletions(-) create mode 100644 src/lib/components/content/ReferencedEventPreview.svelte create mode 100644 src/lib/modules/rss/RSSCommentForm.svelte create mode 100644 src/lib/services/event-links.ts create mode 100644 src/routes/highlights/+page.svelte create mode 100644 src/routes/lists/+page.svelte diff --git a/src/lib/components/content/MetadataCard.svelte b/src/lib/components/content/MetadataCard.svelte index 4b2b9cf..949bc54 100644 --- a/src/lib/components/content/MetadataCard.svelte +++ b/src/lib/components/content/MetadataCard.svelte @@ -1,6 +1,10 @@ + +{#if getReference()} +
+
+ + {referenceType === 'website' ? 'Website:' : referenceType === 'quote' ? 'Quote from:' : referenceType === 'addressable' ? 'Reference:' : 'Reply to:'} + + {#if referenceType === 'website' && websiteUrl} + + + + {:else if loading} + Loading... + {:else if error} + {error} + {:else if referencedEvent} + + {/if} +
+ + {#if referenceType === 'website' && websiteUrl} + + {:else if referencedEvent} + {@const title = getTitle(referencedEvent)} +
+ {#if title} +
{title}
+ {/if} +
{getPreview(referencedEvent)}
+
+ {/if} +
+{/if} + + diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte index c2bc1cf..7412a87 100644 --- a/src/lib/components/layout/Header.svelte +++ b/src/lib/components/layout/Header.svelte @@ -64,7 +64,10 @@ /Relay /Topics /Repos - /Bookmarks + /Highlights + {#if isLoggedIn} + /Lists + {/if} {/if} diff --git a/src/lib/modules/comments/Comment.svelte b/src/lib/modules/comments/Comment.svelte index 1433136..6692a01 100644 --- a/src/lib/modules/comments/Comment.svelte +++ b/src/lib/modules/comments/Comment.svelte @@ -2,7 +2,7 @@ import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MediaAttachments from '../../components/content/MediaAttachments.svelte'; - import ReplyContext from '../../components/content/ReplyContext.svelte'; + import ReferencedEventPreview from '../../components/content/ReferencedEventPreview.svelte'; import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; import DiscussionVoteButtons from '../discussions/DiscussionVoteButtons.svelte'; import EventMenu from '../../components/EventMenu.svelte'; @@ -12,6 +12,10 @@ import { onMount } from 'svelte'; import type { NostrEvent } from '../../types/nostr.js'; import { getKindInfo, KIND } from '../../types/kind-lookup.js'; + import { getEventLink } from '../../services/event-links.js'; + import { goto } from '$app/navigation'; + import IconButton from '../../components/ui/IconButton.svelte'; + import { sessionManager } from '../../services/auth/session-manager.js'; interface Props { comment: NostrEvent; @@ -102,7 +106,7 @@ >
{#if parentEvent} - + {/if}
@@ -111,7 +115,21 @@ {#if getClientName()} via {getClientName()} {/if} -
+
+ goto(getEventLink(comment))} + /> + {#if sessionManager.isLoggedIn() && onReply} + handleReply()} + /> + {/if}
diff --git a/src/lib/modules/discussions/DiscussionCard.svelte b/src/lib/modules/discussions/DiscussionCard.svelte index fbe9aa4..1114e29 100644 --- a/src/lib/modules/discussions/DiscussionCard.svelte +++ b/src/lib/modules/discussions/DiscussionCard.svelte @@ -15,6 +15,9 @@ import { getKindInfo, KIND } from '../../types/kind-lookup.js'; import { stripMarkdown } from '../../services/text-utils.js'; import Icon from '../../components/ui/Icon.svelte'; + import { getEventLink } from '../../services/event-links.js'; + import { goto } from '$app/navigation'; + import IconButton from '../../components/ui/IconButton.svelte'; interface Props { thread: NostrEvent; @@ -218,6 +221,20 @@
{getRelativeTime()} + goto(getEventLink(thread))} + /> + {#if isLoggedIn} + showReplyForm = !showReplyForm} + /> + {/if}
e.stopPropagation()} diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte index f36016e..d1c4af4 100644 --- a/src/lib/modules/feed/FeedPage.svelte +++ b/src/lib/modules/feed/FeedPage.svelte @@ -38,6 +38,9 @@ let initialLoadComplete = $state(false); let loadInProgress = $state(false); + // Preloaded referenced events (e, a, q tags) - eventId -> referenced event + let preloadedReferencedEvents = $state>(new Map()); + // Filtered events based on filterResult let filteredEvents = $derived.by(() => { if (!filterResult.value) { @@ -76,6 +79,49 @@ // Use filteredEvents for display let events = $derived(filteredEvents); + + // Get preloaded referenced event for a post (from e, a, or q tag) + function getReferencedEventForPost(event: NostrEvent): NostrEvent | null { + // Check q tag first + const qTag = event.tags.find(t => t[0] === 'q' && t[1]); + if (qTag && qTag[1]) { + return preloadedReferencedEvents.get(qTag[1]) || null; + } + + // Check e tag + const eTag = event.tags.find(t => t[0] === 'e' && t[1] && t[1] !== event.id); + if (eTag && eTag[1]) { + return preloadedReferencedEvents.get(eTag[1]) || null; + } + + // Check a tag - need to match by kind+pubkey+d-tag + const aTag = event.tags.find(t => t[0] === 'a' && t[1]); + if (aTag && aTag[1]) { + const parts = aTag[1].split(':'); + if (parts.length >= 2) { + const kind = parseInt(parts[0]); + const pubkey = parts[1]; + const dTag = parts[2] || ''; + + // Find matching event in preloaded events + for (const [eventId, refEvent] of preloadedReferencedEvents.entries()) { + if (refEvent.kind === kind && refEvent.pubkey === pubkey) { + if (dTag) { + const eventDTag = refEvent.tags.find(t => t[0] === 'd' && t[1]); + if (eventDTag && eventDTag[1] === dTag) { + return refEvent; + } + } else { + // No d-tag, just match kind and pubkey + return refEvent; + } + } + } + } + } + + return null; + } // Load waiting room events into feed function loadWaitingRoomEvents() { @@ -138,6 +184,9 @@ const sorted = Array.from(eventMap.values()).sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at); allEvents = sorted; oldestTimestamp = Math.min(...sorted.map((e: NostrEvent) => e.created_at)); + + // Batch fetch referenced events for paginated events too + await batchFetchReferencedEvents(filtered); } catch (error) { console.error('Error loading older events:', error); } finally { @@ -218,6 +267,9 @@ if (sorted.length > 0) { oldestTimestamp = Math.min(...sorted.map((e: NostrEvent) => e.created_at)); } + + // Batch fetch referenced events (e, a, q tags) after main events are loaded + await batchFetchReferencedEvents(sorted); } catch (error) { console.error('Error loading feed:', error); if (!events.length) { @@ -229,6 +281,148 @@ loadInProgress = false; } } + + // Collect and batch fetch all referenced events from e, a, q tags + async function batchFetchReferencedEvents(events: NostrEvent[]) { + if (!isMounted || events.length === 0) return; + + const eventIds = new Set(); // For e and q tags + const aTagGroups = new Map(); // For a tags + + // Collect all references + for (const event of events) { + // Check q tag (quote) + const qTag = event.tags.find(t => t[0] === 'q' && t[1]); + if (qTag && qTag[1] && qTag[1] !== event.id) { + eventIds.add(qTag[1]); + } + + // Check e tag (reply) - skip if it's the event's own ID + const eTag = event.tags.find(t => t[0] === 'e' && t[1] && t[1] !== event.id); + if (eTag && eTag[1]) { + eventIds.add(eTag[1]); + } + + // Check a tag (addressable event) + const aTag = event.tags.find(t => t[0] === 'a' && t[1]); + if (aTag && aTag[1]) { + const parts = aTag[1].split(':'); + if (parts.length >= 2) { + const kind = parseInt(parts[0]); + const pubkey = parts[1]; + const dTag = parts[2] || undefined; + const groupKey = `${kind}:${pubkey}:${dTag || ''}`; + if (!aTagGroups.has(groupKey)) { + aTagGroups.set(groupKey, { kind, pubkey, dTag }); + } + } + } + } + + // Remove event IDs that are already in the feed (no need to fetch them) + const feedEventIds = new Set(events.map(e => e.id)); + const eventIdsToFetch = Array.from(eventIds).filter(id => !feedEventIds.has(id)); + + if (eventIdsToFetch.length === 0 && aTagGroups.size === 0) { + return; // Nothing to fetch + } + + const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); + const fetchedEvents = new Map(); + + try { + // Fetch events by ID (e and q tags) in batches + if (eventIdsToFetch.length > 0) { + const batchSize = 100; + for (let i = 0; i < eventIdsToFetch.length; i += batchSize) { + const batch = eventIdsToFetch.slice(i, i + batchSize); + const events = await nostrClient.fetchEvents( + [{ ids: batch, limit: batch.length }], + relays, + { + useCache: true, + cacheResults: true, + priority: 'low', // Low priority - don't block main feed + timeout: config.standardTimeout + } + ); + + for (const event of events) { + fetchedEvents.set(event.id, event); + } + } + } + + // Fetch addressable events (a tags) - group by kind+pubkey+d-tag + if (aTagGroups.size > 0) { + const aTagFilters: any[] = []; + const filterToATag = new Map(); // filter index -> a-tag string + + for (const [groupKey, group] of aTagGroups.entries()) { + const filter: any = { + kinds: [group.kind], + authors: [group.pubkey], + limit: 100 + }; + + if (group.dTag) { + filter['#d'] = [group.dTag]; + } + + const filterIndex = aTagFilters.length; + aTagFilters.push(filter); + filterToATag.set(filterIndex, groupKey); + } + + if (aTagFilters.length > 0) { + const aTagEvents = await nostrClient.fetchEvents( + aTagFilters, + relays, + { + useCache: true, + cacheResults: true, + priority: 'low', + timeout: config.standardTimeout + } + ); + + // Map a-tag events back to their a-tag strings + for (let filterIndex = 0; filterIndex < aTagFilters.length; filterIndex++) { + const filter = aTagFilters[filterIndex]; + const groupKey = filterToATag.get(filterIndex); + if (!groupKey) continue; + + const [, pubkey, dTag] = groupKey.split(':'); + const kind = filter.kinds[0]; + + // Find matching events + const matchingEvents = aTagEvents.filter(e => + e.kind === kind && + e.pubkey === pubkey && + (!dTag || e.tags.find(t => t[0] === 'd' && t[1] === dTag)) + ); + + for (const event of matchingEvents) { + // Store by event ID (will be matched by ReferencedEventPreview) + fetchedEvents.set(event.id, event); + } + } + } + } + + // Update preloaded events map (merge with existing) + if (fetchedEvents.size > 0 && isMounted) { + const merged = new Map(preloadedReferencedEvents); + for (const [id, event] of fetchedEvents.entries()) { + merged.set(id, event); + } + preloadedReferencedEvents = merged; + } + } catch (error) { + console.debug('[FeedPage] Error batch fetching referenced events:', error); + // Don't block on errors - components will fetch individually if needed + } + } // Setup subscription (only adds to waiting room) function setupSubscription() { @@ -308,7 +502,8 @@ {:else}
{#each events as event (event.id)} - + {@const referencedEvent = getReferencedEventForPost(event)} + {/each}
diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index a5a8ca3..54ff465 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -6,6 +6,7 @@ import MetadataCard from '../../components/content/MetadataCard.svelte'; import ReplyContext from '../../components/content/ReplyContext.svelte'; import QuotedContext from '../../components/content/QuotedContext.svelte'; + import ReferencedEventPreview from '../../components/content/ReferencedEventPreview.svelte'; import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; import MediaViewer from '../../components/content/MediaViewer.svelte'; import CommentForm from '../comments/CommentForm.svelte'; @@ -17,6 +18,9 @@ import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js'; import { nip19 } from 'nostr-tools'; import { onMount } from 'svelte'; + import { getEventLink } from '../../services/event-links.js'; + import { goto } from '$app/navigation'; + import IconButton from '../../components/ui/IconButton.svelte'; interface Props { post: NostrEvent; @@ -25,9 +29,10 @@ parentEvent?: NostrEvent; // Optional parent event if already loaded quotedEvent?: NostrEvent; // Optional quoted event if already loaded hideTitle?: boolean; // If true, don't render the title (useful when title is rendered elsewhere) + preloadedReferencedEvent?: NostrEvent | null; // Preloaded referenced event from e/a/q tags } - let { post, fullView = false, preloadedReactions, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, hideTitle = false }: Props = $props(); + let { post, fullView = false, preloadedReactions, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, hideTitle = false, preloadedReferencedEvent }: Props = $props(); // Check if this event is bookmarked (async, so we use state) // Only check if user is logged in @@ -634,6 +639,20 @@ {#if isLoggedIn && bookmarked} 🔖 {/if} + goto(getEventLink(post))} + /> + {#if isLoggedIn} + showReplyForm = !showReplyForm} + /> + {/if} showReplyForm = !showReplyForm} />

@@ -679,6 +698,9 @@ {/if} + + + {#if !fullView && (!post.content || !post.content.trim())} @@ -787,6 +809,20 @@ {#if isLoggedIn && bookmarked} 🔖 {/if} + goto(getEventLink(post))} + /> + {#if isLoggedIn} + showReplyForm = !showReplyForm} + /> + {/if} showReplyForm = !showReplyForm} />
diff --git a/src/lib/modules/feed/HighlightCard.svelte b/src/lib/modules/feed/HighlightCard.svelte index 0e3ce77..229a8df 100644 --- a/src/lib/modules/feed/HighlightCard.svelte +++ b/src/lib/modules/feed/HighlightCard.svelte @@ -11,6 +11,9 @@ import { isBookmarked } from '../../services/user-actions.js'; import { sessionManager } from '../../services/auth/session-manager.js'; import Icon from '../../components/ui/Icon.svelte'; + import { getEventLink } from '../../services/event-links.js'; + import { goto } from '$app/navigation'; + import IconButton from '../../components/ui/IconButton.svelte'; interface Props { highlight: NostrEvent; // The highlight event (kind 9802) @@ -322,7 +325,23 @@ {/if}
{#if isLoggedIn && bookmarked} - + + + + {/if} + goto(getEventLink(highlight))} + /> + {#if isLoggedIn} + {}} + /> {/if} {}} />
diff --git a/src/lib/modules/feed/Reply.svelte b/src/lib/modules/feed/Reply.svelte index 851815b..b34e0d8 100644 --- a/src/lib/modules/feed/Reply.svelte +++ b/src/lib/modules/feed/Reply.svelte @@ -7,6 +7,10 @@ import EventMenu from '../../components/EventMenu.svelte'; import type { NostrEvent } from '../../types/nostr.js'; import { getKindInfo } from '../../types/kind-lookup.js'; + import { getEventLink } from '../../services/event-links.js'; + import { goto } from '$app/navigation'; + import IconButton from '../../components/ui/IconButton.svelte'; + import { sessionManager } from '../../services/auth/session-manager.js'; interface Props { reply: NostrEvent; @@ -91,7 +95,21 @@ {#if getClientName()} via {getClientName()} {/if} -
+
+ goto(getEventLink(reply))} + /> + {#if sessionManager.isLoggedIn() && onReply} + onReply(reply)} + /> + {/if} {}} />
diff --git a/src/lib/modules/feed/ZapReceiptReply.svelte b/src/lib/modules/feed/ZapReceiptReply.svelte index 0a8671c..923c5bf 100644 --- a/src/lib/modules/feed/ZapReceiptReply.svelte +++ b/src/lib/modules/feed/ZapReceiptReply.svelte @@ -5,6 +5,9 @@ import type { NostrEvent } from '../../types/nostr.js'; import { getKindInfo } from '../../types/kind-lookup.js'; import Icon from '../../components/ui/Icon.svelte'; + import { getEventLink } from '../../services/event-links.js'; + import { goto } from '$app/navigation'; + import IconButton from '../../components/ui/IconButton.svelte'; interface Props { zapReceipt: NostrEvent; // Kind 9735 zap receipt @@ -103,7 +106,21 @@ {getAmount().toLocaleString()} sats {getRelativeTime()} -
+
+ goto(getEventLink(zapReceipt))} + /> + {#if onReply} + onReply(zapReceipt)} + /> + {/if}
diff --git a/src/lib/modules/profiles/ProfilePage.svelte b/src/lib/modules/profiles/ProfilePage.svelte index 1c504cf..1847477 100644 --- a/src/lib/modules/profiles/ProfilePage.svelte +++ b/src/lib/modules/profiles/ProfilePage.svelte @@ -6,7 +6,6 @@ import CommentComponent from '../comments/Comment.svelte'; import ProfileEventsPanel from '../../components/profile/ProfileEventsPanel.svelte'; import ProfileMenu from '../../components/profile/ProfileMenu.svelte'; - import BookmarksPanel from '../../components/profile/BookmarksPanel.svelte'; import { fetchProfile, fetchUserStatus, fetchUserStatusEvent, type ProfileData } from '../../services/user-data.js'; import { nostrClient } from '../../services/nostr/nostr-client.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; @@ -30,7 +29,7 @@ let wallComments = $state([]); // Kind 1111 comments on the wall let loading = $state(true); let loadingWall = $state(false); - let activeTab = $state<'pins' | 'notifications' | 'interactions' | 'wall'>('pins'); + let activeTab = $state<'pins' | 'notifications' | 'interactions' | 'wall' | 'bookmarks'>('pins'); let nip05Validations = $state>({}); // null = checking, true = valid, false = invalid // Compute pubkey from route params let profilePubkey = $derived.by(() => decodePubkey($page.params.pubkey)); @@ -45,12 +44,13 @@ // Profile events panel state let profileEventsPanelOpen = $state(false); - // Bookmarks panel state - let bookmarksPanelOpen = $state(false); - // Pins state let pins = $state([]); + // Bookmarks state + let bookmarks = $state([]); + let loadingBookmarks = $state(false); + // Cleanup tracking let isMounted = $state(true); let activeFetchPromises = $state>>(new Set()); @@ -79,14 +79,6 @@ profileEventsPanelOpen = false; } - function openBookmarksPanel() { - bookmarksPanelOpen = true; - } - - function closeBookmarksPanel() { - bookmarksPanelOpen = false; - } - const isOwnProfile = $derived.by(() => { const pubkey = decodePubkey($page.params.pubkey); return currentUserPubkey && pubkey && currentUserPubkey === pubkey; @@ -238,6 +230,80 @@ } } + async function loadBookmarks(pubkey: string) { + if (!isMounted) return; + loadingBookmarks = true; + try { + // Fetch the user's bookmark list (kind 10003) + const profileRelays = relayManager.getProfileReadRelays(); + const bookmarkLists = await nostrClient.fetchEvents( + [{ kinds: [KIND.BOOKMARKS], authors: [pubkey], limit: 400 }], + profileRelays, + { useCache: true, cacheResults: true, timeout: config.mediumTimeout } + ); + + if (!isMounted || bookmarkLists.length === 0) { + if (isMounted) { + bookmarks = []; + loadingBookmarks = false; + } + return; + } + + // Extract event IDs from bookmark lists + const bookmarkedIds = new Set(); + for (const bookmarkList of bookmarkLists) { + for (const tag of bookmarkList.tags) { + if (tag[0] === 'e' && tag[1]) { + bookmarkedIds.add(tag[1]); + } + } + } + + if (bookmarkedIds.size === 0) { + if (isMounted) { + bookmarks = []; + loadingBookmarks = false; + } + return; + } + + // Fetch the actual bookmarked events in batches + const batchSize = 100; + const allBookmarkedEvents: NostrEvent[] = []; + const bookmarkedIdsArray = Array.from(bookmarkedIds); + + for (let i = 0; i < bookmarkedIdsArray.length; i += batchSize) { + const batch = bookmarkedIdsArray.slice(i, i + batchSize); + const fetchPromise = nostrClient.fetchEvents( + [{ ids: batch, limit: batch.length }], + profileRelays, + { useCache: true, cacheResults: true, timeout: config.mediumTimeout } + ); + activeFetchPromises.add(fetchPromise); + const batchEvents = await fetchPromise; + activeFetchPromises.delete(fetchPromise); + + if (!isMounted) return; + allBookmarkedEvents.push(...batchEvents); + } + + if (!isMounted) return; + + // Sort by created_at descending + bookmarks = allBookmarkedEvents.sort((a, b) => b.created_at - a.created_at); + } catch (error) { + console.error('Error loading bookmarks:', error); + if (isMounted) { + bookmarks = []; + } + } finally { + if (isMounted) { + loadingBookmarks = false; + } + } + } + async function loadNotifications(pubkey: string) { if (!isMounted) return; try { @@ -616,6 +682,9 @@ // Step 2: Load pins for the profile being viewed await loadPins(pubkey); + // Step 2.5: Load bookmarks for the profile being viewed + await loadBookmarks(pubkey); + // Step 3: Load notifications or interactions if (isOwnProfile) { await loadNotifications(pubkey); @@ -729,7 +798,7 @@
{nip19.npubEncode(profilePubkey)} - +
{/if} @@ -783,6 +852,12 @@ Interactions with me ({interactionsWithMe.length}) {/if} +
{#if activeTab === 'pins'} @@ -857,6 +932,18 @@ {/each}
{/if} + {:else if activeTab === 'bookmarks'} + {#if loadingBookmarks} +

Loading bookmarks...

+ {:else if bookmarks.length === 0} +

No bookmarks yet.

+ {:else} +
+ {#each bookmarks as bookmark (bookmark.id)} + + {/each} +
+ {/if} {/if}
{:else} @@ -869,10 +956,6 @@ pubkey={decodePubkey($page.params.pubkey) || ''} onClose={closeProfileEventsPanel} /> - {/if} diff --git a/src/lib/modules/rss/RSSCommentForm.svelte b/src/lib/modules/rss/RSSCommentForm.svelte new file mode 100644 index 0000000..5481990 --- /dev/null +++ b/src/lib/modules/rss/RSSCommentForm.svelte @@ -0,0 +1,671 @@ + + +
+ + +
+
+ + + +
+
+ {#if onCancel} + + {/if} + +
+
+ + + {#if showJsonModal} + + {/if} + + + {#if showPreviewModal} + + {/if} +
+ + diff --git a/src/lib/services/event-links.ts b/src/lib/services/event-links.ts new file mode 100644 index 0000000..4d8ac2b --- /dev/null +++ b/src/lib/services/event-links.ts @@ -0,0 +1,46 @@ +/** + * Utility functions for generating event links + */ + +import { nip19 } from 'nostr-tools'; +import type { NostrEvent } from '../types/nostr.js'; + +/** + * Generate a link to view an event + * Returns nevent for regular events, naddr for parameterized replaceable events + */ +export function getEventLink(event: NostrEvent): string { + // Check if this is a parameterized replaceable event (kind 30000-39999) + if (event.kind >= 30000 && event.kind <= 39999) { + const dTag = event.tags.find(t => t[0] === 'd' && t[1]); + if (dTag && dTag[1]) { + // Generate naddr for parameterized replaceable events + try { + const naddr = nip19.naddrEncode({ + kind: event.kind, + pubkey: event.pubkey, + identifier: dTag[1], + relays: [] + }); + return `/event/${naddr}`; + } catch (error) { + console.error('Error encoding naddr:', error); + // Fallback to nevent + } + } + } + + // For regular events, use nevent + try { + const nevent = nip19.neventEncode({ + id: event.id, + author: event.pubkey, + relays: [] + }); + return `/event/${nevent}`; + } catch (error) { + console.error('Error encoding nevent:', error); + // Fallback to hex ID + return `/event/${event.id}`; + } +} diff --git a/src/lib/types/kind-lookup.ts b/src/lib/types/kind-lookup.ts index 4149e2b..fbfdf58 100644 --- a/src/lib/types/kind-lookup.ts +++ b/src/lib/types/kind-lookup.ts @@ -58,6 +58,7 @@ export const KIND = { METADATA: 0, SHORT_TEXT_NOTE: 1, CONTACTS: 3, + FOLLOW_SET: 30000, EVENT_DELETION: 5, REACTION: 7, DISCUSSION_THREAD: 11, @@ -163,7 +164,7 @@ export const KIND_LOOKUP: Record = { [KIND.EMOJI_PACK]: { number: KIND.EMOJI_PACK, description: 'Emoji Pack', showInFeed: false, isSecondaryKind: false }, [KIND.MUTE_LIST]: { number: KIND.MUTE_LIST, description: 'Mute List', showInFeed: false, isSecondaryKind: false }, [KIND.BADGES]: { number: KIND.BADGES, description: 'Badges', showInFeed: false, isSecondaryKind: false }, - [KIND.FOLOW_SET]: { number: KIND.FOLOW_SET, description: 'Follow Set', showInFeed: false, isSecondaryKind: false }, + [KIND.FOLLOW_SET]: { number: KIND.FOLLOW_SET, description: 'Follow Set', showInFeed: false, isSecondaryKind: false }, [KIND.HTTP_AUTH]: { number: KIND.HTTP_AUTH, description: 'HTTP Auth', showInFeed: false, isSecondaryKind: false }, // Repository (NIP-34) diff --git a/src/routes/bookmarks/+page.svelte b/src/routes/bookmarks/+page.svelte index 034b873..bc339b0 100644 --- a/src/routes/bookmarks/+page.svelte +++ b/src/routes/bookmarks/+page.svelte @@ -194,10 +194,10 @@ console.log(`[Bookmarks] Found ${highlightBySourceEvent.size} e-tag references and ${aTagHighlights.size} a-tag references`); console.log(`[Bookmarks] Highlights breakdown: ${highlightsWithETags} with e-tags, ${highlightsWithATags} with a-tags only, ${highlightsWithNoRefs} with no event references`); - // Second pass: fetch events for a-tags in batches + // Second pass: fetch events for a-tags in batches (grouped by kind+pubkey+d-tag) if (aTagHighlights.size > 0) { - const aTagFilters: any[] = []; - const aTagToPubkey = new Map(); + // Group a-tags by kind+pubkey+d-tag to create efficient filters + const aTagGroups = new Map(); for (const [aTag, info] of aTagHighlights.entries()) { const aTagParts = aTag.split(':'); @@ -206,22 +206,49 @@ const pubkey = aTagParts[1]; const dTag = aTagParts[2] || ''; + // Create a key for grouping: kind:pubkey:d-tag + const groupKey = `${kind}:${pubkey}:${dTag}`; + + if (!aTagGroups.has(groupKey)) { + aTagGroups.set(groupKey, { + aTags: [], + pubkey: info.pubkey, + kind, + dTag: dTag || undefined + }); + } + aTagGroups.get(groupKey)!.aTags.push(aTag); + } + } + + // Create batched filters (one per group) + const aTagFilters: any[] = []; + const filterToATags = new Map(); // filter index -> a-tags + + for (const [groupKey, group] of aTagGroups.entries()) { + // Extract pubkey from the first a-tag in the group + const firstATag = group.aTags[0]; + const aTagParts = firstATag.split(':'); + if (aTagParts.length >= 2) { + const pubkey = aTagParts[1]; + const filter: any = { - kinds: [kind], + kinds: [group.kind], authors: [pubkey], - limit: 1 + limit: 100 // Fetch up to 100 events for this kind+pubkey combination }; - if (dTag) { - filter['#d'] = [dTag]; + if (group.dTag) { + filter['#d'] = [group.dTag]; } + const filterIndex = aTagFilters.length; aTagFilters.push(filter); - aTagToPubkey.set(aTag, info.pubkey); + filterToATags.set(filterIndex, group.aTags); } } - // Fetch all a-tag events + // Fetch all a-tag events in one batch if (aTagFilters.length > 0) { try { const aTagEvents = await nostrClient.fetchEvents( @@ -235,34 +262,57 @@ ); // Match a-tag events back to highlights - for (const event of aTagEvents) { - // Find which a-tag this event matches - for (const [aTag, info] of aTagHighlights.entries()) { - const aTagParts = aTag.split(':'); - if (aTagParts.length >= 2) { - const kind = parseInt(aTagParts[0]); - const pubkey = aTagParts[1]; - const dTag = aTagParts[2] || ''; - - if (event.kind === kind && event.pubkey === pubkey) { - // Check d-tag if present - if (dTag) { - const eventDTag = event.tags.find(t => t[0] === 'd' && t[1]); - if (eventDTag && eventDTag[1] === dTag) { - highlightBySourceEvent.set(event.id, { highlight: info.highlight, authorPubkey: info.pubkey }); + const eventToATag = new Map(); // event id -> a-tag + + for (let filterIndex = 0; filterIndex < aTagFilters.length; filterIndex++) { + const filter = aTagFilters[filterIndex]; + const aTags = filterToATags.get(filterIndex) || []; + const kind = filter.kinds[0]; + const pubkey = filter.authors[0]; + const dTag = filter['#d']?.[0]; + + // Find events that match this filter + const matchingEvents = aTagEvents.filter(event => + event.kind === kind && + event.pubkey === pubkey && + (!dTag || event.tags.find(t => t[0] === 'd' && t[1] === dTag)) + ); + + // Match events to a-tags + for (const event of matchingEvents) { + for (const aTag of aTags) { + const aTagParts = aTag.split(':'); + if (aTagParts.length >= 2) { + const aTagKind = parseInt(aTagParts[0]); + const aTagPubkey = aTagParts[1]; + const aTagDTag = aTagParts[2] || ''; + + if (event.kind === aTagKind && event.pubkey === aTagPubkey) { + if (aTagDTag) { + const eventDTag = event.tags.find(t => t[0] === 'd' && t[1]); + if (eventDTag && eventDTag[1] === aTagDTag) { + eventToATag.set(event.id, aTag); + break; + } + } else { + eventToATag.set(event.id, aTag); break; } - } else { - // No d-tag, just match kind and pubkey - highlightBySourceEvent.set(event.id, { highlight: info.highlight, authorPubkey: info.pubkey }); - break; } } } } } - console.log(`[Bookmarks] Resolved ${aTagEvents.length} events from a-tags`); + // Map events to highlights + for (const [eventId, aTag] of eventToATag.entries()) { + const info = aTagHighlights.get(aTag); + if (info) { + highlightBySourceEvent.set(eventId, { highlight: info.highlight, authorPubkey: info.pubkey }); + } + } + + console.log(`[Bookmarks] Resolved ${eventToATag.size} events from ${aTagGroups.size} a-tag groups`); } catch (err) { console.error('[Bookmarks] Error fetching events for a-tags:', err); } @@ -368,6 +418,34 @@ // Sort by created_at (newest first) and limit to maxTotalItems allItems = items.sort((a, b) => b.event.created_at - a.event.created_at).slice(0, maxTotalItems); + + // Pre-fetch all profiles for event authors in one batch to avoid individual fetches + // This prevents ProfileBadge components from making hundreds of individual requests + const uniquePubkeys = new Set(); + for (const item of allItems) { + uniquePubkeys.add(item.event.pubkey); + uniquePubkeys.add(item.authorPubkey); + } + + if (uniquePubkeys.size > 0) { + const profileRelays = relayManager.getProfileReadRelays(); + const pubkeyArray = Array.from(uniquePubkeys); + + // Batch fetch all profiles at once (fire and forget - don't block) + nostrClient.fetchEvents( + [{ kinds: [KIND.METADATA], authors: pubkeyArray, limit: 1 }], + profileRelays, + { + useCache: true, + cacheResults: true, + priority: 'low', // Low priority - let events load first + timeout: config.standardTimeout + } + ).catch(err => { + console.debug('[Bookmarks] Error pre-fetching profiles:', err); + // Don't block on profile fetch errors + }); + } } catch (err) { console.error('Error loading bookmarks and highlights:', err); error = err instanceof Error ? err.message : 'Failed to load bookmarks and highlights'; diff --git a/src/routes/highlights/+page.svelte b/src/routes/highlights/+page.svelte new file mode 100644 index 0000000..8b1df3f --- /dev/null +++ b/src/routes/highlights/+page.svelte @@ -0,0 +1,769 @@ + + +
+ +
+
+

/Highlights

+ + {#if loading} +
+

Loading highlights...

+
+ {:else if error} +
+

{error}

+
+ {:else if allItems.length === 0} +
+

No highlights found.

+
+ {:else} +
+
+
+ +
+
+ + {#if totalPages > 1 && !searchResults.events.length && !searchResults.profiles.length} + + {/if} +
+ + {#if searchResults.events.length > 0 || searchResults.profiles.length > 0} +
+

Search Results

+ + {#if searchResults.profiles.length > 0} +
+ {/if} + + {#if searchResults.events.length > 0} +
+

Events ({searchResults.events.length})

+
+ {#each searchResults.events as event} + {#if event.kind === KIND.HIGHLIGHTED_ARTICLE} +
+ goto(`/event/${e.id}`)} /> +
+ {/if} + {/each} +
+
+ {/if} +
+ {:else} +
+

+ Showing {paginatedItems.length} of {filteredItems.length} items + {#if allItems.length >= maxTotalItems} + (limited to {maxTotalItems}) + {/if} + {#if filterResult.value} + (filtered) + {/if} +

+
+ +
+ {#each paginatedItems as item (item.event.id)} +
+ goto(`/event/${event.id}`)} /> +
+ {/each} +
+ + {#if totalPages > 1} + + {/if} + {/if} + {/if} +
+
+ + diff --git a/src/routes/lists/+page.svelte b/src/routes/lists/+page.svelte new file mode 100644 index 0000000..68b8dd5 --- /dev/null +++ b/src/routes/lists/+page.svelte @@ -0,0 +1,265 @@ + + +
+ +
+ {#if !isLoggedIn} +
+

Please log in to view your lists.

+ Go to login +
+ {:else if loading} +
+

Loading lists...

+
+ {:else if !hasLists} +
+

/Lists

+

You don't have any lists yet.

+

+ Create a kind 3 (contacts) or kind 30000 (follow_set) event to get started. +

+
+ {:else} +
+

/Lists

+ +
+ + +
+
+ + {#if loadingEvents} +
+

Loading events...

+
+ {:else if selectedList && events.length === 0} +
+

No events found for this list.

+
+ {:else if selectedList} +
+ {#each events as event (event.id)} + + {/each} +
+ {/if} + {/if} +
+ + diff --git a/src/routes/rss/+page.svelte b/src/routes/rss/+page.svelte index e338257..b9824c1 100644 --- a/src/routes/rss/+page.svelte +++ b/src/routes/rss/+page.svelte @@ -9,6 +9,8 @@ import type { NostrEvent } from '../../lib/types/nostr.js'; import { KIND } from '../../lib/types/kind-lookup.js'; import MarkdownRenderer from '../../lib/components/content/MarkdownRenderer.svelte'; + import RSSCommentForm from '../../lib/modules/rss/RSSCommentForm.svelte'; + import IconButton from '../../lib/components/ui/IconButton.svelte'; const RSS_FEED_KIND = 10895; @@ -282,6 +284,23 @@ function handleCreateRss() { goto(`/write?kind=${RSS_FEED_KIND}`); } + + // Track which RSS item has reply form open + let openReplyFormFor = $state(null); // URL of the item + + function toggleReplyForm(itemLink: string) { + if (openReplyFormFor === itemLink) { + openReplyFormFor = null; + } else { + openReplyFormFor = itemLink; + } + } + + // Generate a deterministic thread ID from the URL (for RSS items, we use the URL as threadId) + function getThreadIdFromUrl(url: string): string { + // Use the URL as the threadId for RSS items + return url; + }
@@ -390,6 +409,29 @@ {/if} + {#if sessionManager.isLoggedIn()} +
+ toggleReplyForm(item.link)} + /> +
+ {/if} + {#if openReplyFormFor === item.link} +
+ { + openReplyFormFor = null; + }} + onCancel={() => { + openReplyFormFor = null; + }} + /> +
+ {/if} {/each}