From bf79bbceb8d98474ef3ad4d3d57ef2ecd902b56c Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 2 Oct 2025 09:05:32 +0200 Subject: [PATCH] feat: implement individual bookmark fetching and display - Add IndividualBookmark interface for individual bookmark events - Implement fetchIndividualBookmarks function to fetch events by e and a tags - Update parseBookmarkEvent to be async and fetch individual bookmarks - Add renderIndividualBookmark component for displaying individual bookmarks - Update UI to show individual bookmarks in a grid layout - Add CSS styles for individual bookmarks with dark/light mode support - Support both event references (e tags) and article references (a tags) - Use applesauce content parsing for proper content rendering --- src/components/Bookmarks.tsx | 147 +++++++++++++++++++++++++++++++++-- src/index.css | 111 ++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 6 deletions(-) diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index a27687a8..62972930 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -33,6 +33,19 @@ interface Bookmark { articleReferences?: string[] urlReferences?: string[] parsedContent?: ParsedContent + individualBookmarks?: IndividualBookmark[] +} + +interface IndividualBookmark { + id: string + content: string + created_at: number + pubkey: string + kind: number + tags: string[][] + parsedContent?: ParsedContent + author?: string + type: 'event' | 'article' } interface BookmarksProps { @@ -110,7 +123,7 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { const bookmarkList: Bookmark[] = [] for (const event of uniqueEvents) { console.log('Processing bookmark event:', event) - const bookmarkData = parseBookmarkEvent(event) + const bookmarkData = await parseBookmarkEvent(event) if (bookmarkData) { bookmarkList.push(bookmarkData) console.log('Parsed bookmark:', bookmarkData) @@ -127,7 +140,85 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { } } - const parseBookmarkEvent = (event: NostrEvent): Bookmark | null => { + const fetchIndividualBookmarks = async (eventIds: string[], articleIds: string[]): Promise => { + if (!relayPool || (eventIds.length === 0 && articleIds.length === 0)) { + return [] + } + + try { + const allIds = [...eventIds, ...articleIds] + console.log('Fetching individual bookmarks for IDs:', allIds.length) + + // Create filters for both event IDs and article IDs + const eventFilters: Filter[] = [] + + if (eventIds.length > 0) { + eventFilters.push({ + ids: eventIds + }) + } + + if (articleIds.length > 0) { + // For article IDs, we need to parse the kind and pubkey from the 'a' tag + const articleFilters = articleIds.map(articleId => { + const [kind, pubkey, identifier] = articleId.split(':') + return { + kinds: [parseInt(kind)], + authors: [pubkey], + '#d': [identifier] + } + }) + eventFilters.push(...articleFilters) + } + + const allEvents: NostrEvent[] = [] + + // Fetch events for each filter + for (const filter of eventFilters) { + const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + const events = await lastValueFrom( + relayPool.req(relayUrls, filter).pipe( + completeOnEose(), + takeUntil(timer(10000)), + toArray(), + ) + ) + allEvents.push(...events) + } + + // Deduplicate events + const uniqueEvents = allEvents.reduce((acc, event) => { + if (!acc.find(e => e.id === event.id)) { + acc.push(event) + } + return acc + }, [] as NostrEvent[]) + + console.log('Fetched individual bookmarks:', uniqueEvents.length) + + // Convert to IndividualBookmark format + return uniqueEvents.map(event => { + const parsedContent = event.content ? getParsedContent(event.content) as ParsedContent : undefined + const isArticle = articleIds.includes(event.id) || event.tags.some(tag => tag[0] === 'a') + + return { + id: event.id, + content: event.content, + created_at: event.created_at, + pubkey: event.pubkey, + kind: event.kind, + tags: event.tags, + parsedContent: parsedContent, + type: isArticle ? 'article' : 'event' + } + }) + } catch (error) { + console.error('Error fetching individual bookmarks:', error) + return [] + } + } + + const parseBookmarkEvent = async (event: NostrEvent): Promise => { try { // According to NIP-51, bookmark lists (kind 10003) contain: // - "e" tags for event references (the actual bookmarks) @@ -144,6 +235,11 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { // Get the title from content or use a default const title = event.content || `Bookmark List (${eventTags.length + articleTags.length + urlTags.length} items)` + // Fetch individual bookmarks + const eventIds = eventTags.map(tag => tag[1]) + const articleIds = articleTags.map(tag => tag[1]) + const individualBookmarks = await fetchIndividualBookmarks(eventIds, articleIds) + return { id: event.id, title: title, @@ -154,9 +250,10 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { parsedContent: parsedContent, // Add metadata about the bookmark list bookmarkCount: eventTags.length + articleTags.length + urlTags.length, - eventReferences: eventTags.map(tag => tag[1]), - articleReferences: articleTags.map(tag => tag[1]), - urlReferences: urlTags.map(tag => tag[1]) + eventReferences: eventIds, + articleReferences: articleIds, + urlReferences: urlTags.map(tag => tag[1]), + individualBookmarks: individualBookmarks } } catch (error) { console.error('Error parsing bookmark event:', error) @@ -229,6 +326,34 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { ) } + // Component to render individual bookmarks + const renderIndividualBookmark = (bookmark: IndividualBookmark, index: number) => { + return ( +
+
+ {bookmark.type} + {bookmark.id.slice(0, 8)}...{bookmark.id.slice(-8)} + {formatDate(bookmark.created_at)} +
+ + {bookmark.parsedContent ? ( +
+ {renderParsedContent(bookmark.parsedContent)} +
+ ) : bookmark.content && ( +
+

{bookmark.content}

+
+ )} + +
+ Kind: {bookmark.kind} + Author: {bookmark.pubkey.slice(0, 8)}...{bookmark.pubkey.slice(-8)} +
+
+ ) + } + const formatUserDisplay = () => { if (!activeAccount) return 'Unknown User' @@ -305,7 +430,17 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { ))} )} - {bookmark.eventReferences && bookmark.eventReferences.length > 0 && ( + {bookmark.individualBookmarks && bookmark.individualBookmarks.length > 0 && ( +
+

Individual Bookmarks ({bookmark.individualBookmarks.length}):

+
+ {bookmark.individualBookmarks.map((individualBookmark, index) => + renderIndividualBookmark(individualBookmark, index) + )} +
+
+ )} + {bookmark.eventReferences && bookmark.eventReferences.length > 0 && bookmark.individualBookmarks?.length === 0 && (

Event References ({bookmark.eventReferences.length}):

diff --git a/src/index.css b/src/index.css index 145b5d68..910a2656 100644 --- a/src/index.css +++ b/src/index.css @@ -288,6 +288,90 @@ body { margin-top: 0.5rem; } +/* Individual Bookmarks Styles */ +.individual-bookmarks { + margin: 1rem 0; +} + +.individual-bookmarks h4 { + margin: 0 0 1rem 0; + font-size: 1rem; + color: #fff; +} + +.bookmarks-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); +} + +.individual-bookmark { + background: #2a2a2a; + padding: 1rem; + border-radius: 6px; + border: 1px solid #444; + transition: border-color 0.2s; +} + +.individual-bookmark:hover { + border-color: #646cff; +} + +.bookmark-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + flex-wrap: wrap; + gap: 0.5rem; +} + +.bookmark-type { + background: #646cff; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; + text-transform: uppercase; +} + +.bookmark-id { + font-family: monospace; + font-size: 0.8rem; + color: #888; + background: #1a1a1a; + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.bookmark-date { + font-size: 0.8rem; + color: #666; +} + +.individual-bookmark .bookmark-content { + margin: 0.75rem 0; + color: #ccc; + line-height: 1.5; +} + +.individual-bookmark .bookmark-meta { + display: flex; + gap: 1rem; + flex-wrap: wrap; + font-size: 0.8rem; + color: #888; + margin-top: 0.75rem; +} + +.individual-bookmark .bookmark-meta span { + background: #1a1a1a; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-family: monospace; +} + @media (prefers-color-scheme: light) { :root { color: #213547; @@ -315,4 +399,31 @@ body { .user-info { color: #666; } + + .individual-bookmark { + background: #f5f5f5; + border-color: #ddd; + } + + .individual-bookmark:hover { + border-color: #646cff; + } + + .individual-bookmarks h4 { + color: #213547; + } + + .individual-bookmark .bookmark-content { + color: #666; + } + + .bookmark-id { + background: #e9ecef; + color: #666; + } + + .individual-bookmark .bookmark-meta span { + background: #e9ecef; + color: #666; + } }