From 31dfae821c399ad745a7e2bf98b0e9b5d38fb237 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 7 Jun 2026 17:57:51 +0200 Subject: [PATCH] label books --- .../Library/LibraryPublicationGrid.tsx | 59 ++- .../Note/PublicationBooklistButton.tsx | 54 +++ .../Note/PublicationIndexMetadata.tsx | 39 +- src/hooks/useLibraryPublications.ts | 85 +++- src/hooks/usePublicationBooklist.ts | 98 +++++ src/i18n/locales/de.ts | 2 +- src/i18n/locales/en.ts | 10 +- src/lib/booklist-label.test.ts | 72 +++ src/lib/booklist-label.ts | 74 ++++ src/lib/draft-event.ts | 15 + src/lib/library-publication-index.test.ts | 116 ++++- src/lib/library-publication-index.ts | 414 +++++++++++++----- src/lib/nip32-label.test.ts | 12 +- src/lib/nip32-label.ts | 14 + 14 files changed, 914 insertions(+), 150 deletions(-) create mode 100644 src/components/Note/PublicationBooklistButton.tsx create mode 100644 src/hooks/usePublicationBooklist.ts create mode 100644 src/lib/booklist-label.test.ts create mode 100644 src/lib/booklist-label.ts diff --git a/src/components/Library/LibraryPublicationGrid.tsx b/src/components/Library/LibraryPublicationGrid.tsx index e17bcfbb..af1a70ba 100644 --- a/src/components/Library/LibraryPublicationGrid.tsx +++ b/src/components/Library/LibraryPublicationGrid.tsx @@ -1,32 +1,73 @@ import PublicationCard from '@/components/Note/PublicationCard' import { Skeleton } from '@/components/ui/skeleton' import type { LibraryPublicationEntry } from '@/lib/library-publication-index' +import { isBooklistNip32Label } from '@/lib/nip32-label' import { cn } from '@/lib/utils' -import { Highlighter, MessageSquare, Tag } from 'lucide-react' +import { BookOpen, Highlighter, MessageSquare, Tag } from 'lucide-react' import { useTranslation } from 'react-i18next' +function LabelBadgeIcon({ name }: { name: string }) { + if (isBooklistNip32Label(name)) { + return + } + return +} + function EngagementBadges({ entry }: { entry: LibraryPublicationEntry }) { const { t } = useTranslation() - if (!entry.hasLabel && !entry.hasComment && !entry.hasHighlight) return null + const otherLabels = entry.labelNames.filter((name) => !isBooklistNip32Label(name)) + if ( + !entry.hasBooklistLabel && + otherLabels.length === 0 && + !entry.hasLabel && + !entry.hasComment && + !entry.hasHighlight + ) { + return null + } return (
+ {entry.hasBooklistLabel ? ( + + + + {entry.hasMyBooklistLabel + ? t('Library badge my booklist') + : t('Library badge booklist')} + + + ) : null} {entry.hasLabel && - (entry.labelNames.length > 0 ? ( - entry.labelNames.map((name) => ( + (otherLabels.length > 0 ? ( + otherLabels.map((name) => ( - + {name} )) ) : ( - - - {t('Library badge label')} - + !entry.hasBooklistLabel ? ( + + + {t('Library badge label')} + + ) : null ))} {entry.hasComment && ( diff --git a/src/components/Note/PublicationBooklistButton.tsx b/src/components/Note/PublicationBooklistButton.tsx new file mode 100644 index 00000000..05936bb2 --- /dev/null +++ b/src/components/Note/PublicationBooklistButton.tsx @@ -0,0 +1,54 @@ +import { Button } from '@/components/ui/button' +import { usePublicationBooklist } from '@/hooks/usePublicationBooklist' +import { cn } from '@/lib/utils' +import { BookOpen, Loader2 } from 'lucide-react' +import { Event } from 'nostr-tools' +import { useTranslation } from 'react-i18next' + +export default function PublicationBooklistButton({ + event, + className +}: { + event: Event + className?: string +}) { + const { t } = useTranslation() + const { isOnBooklist, loading, toggling, toggle, canToggle } = usePublicationBooklist(event) + + if (!canToggle) return null + + return ( + + ) +} diff --git a/src/components/Note/PublicationIndexMetadata.tsx b/src/components/Note/PublicationIndexMetadata.tsx index ef4912c7..a1c08505 100644 --- a/src/components/Note/PublicationIndexMetadata.tsx +++ b/src/components/Note/PublicationIndexMetadata.tsx @@ -13,6 +13,7 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import PublicationCoverFallback from './PublicationCoverFallback' import PublicationCoverImage from './PublicationCoverImage' +import PublicationBooklistButton from './PublicationBooklistButton' function formatAuthorLine(authors: PublicationAuthor[]): string { if (authors.length === 0) return '' @@ -176,24 +177,30 @@ export default function PublicationIndexMetadata({
) : null} - {metadata.source ? ( - e.stopPropagation()} - > - - {sourceHostname(metadata.source)} - + {metadata.source || tagsComponent || isFull ? ( +
+ {metadata.source ? ( + e.stopPropagation()} + > + + {sourceHostname(metadata.source)} + + ) : null} + + {tagsComponent} + + {isFull ? : null} +
) : null} - {tagsComponent} - {isFull && metadata.sections.length > 0 ? (
diff --git a/src/hooks/useLibraryPublications.ts b/src/hooks/useLibraryPublications.ts index bbc675b6..010df0a4 100644 --- a/src/hooks/useLibraryPublications.ts +++ b/src/hooks/useLibraryPublications.ts @@ -2,13 +2,17 @@ import { clearAllLibraryIndexCaches, filterLibraryPublicationsByUser, buildLibraryRelayUrls, + libraryPublicationEntriesFromIndex, loadLibraryPublicationIndex, peekLibrarySearchResults, + refreshLibraryEngagement, searchLibraryPublications, searchLibraryPublicationsOnRelays, type LibraryPublicationEntry, type PublicationEngagementMaps } from '@/lib/library-publication-index' +import { BOOKLIST_LABEL_UPDATED_EVENT } from '@/lib/booklist-label' +import { fetchNewestPinListForPubkey } from '@/lib/replaceable-list-latest' import { getTopLevelIndexEvents } from '@/lib/publication-index' import logger from '@/lib/logger' import { useNostr } from '@/providers/NostrProvider' @@ -23,12 +27,20 @@ const EMPTY_ENGAGEMENT: PublicationEngagementMaps = { labelEventIds: new Set(), labelValuesByAddress: new Map(), labelValuesByEventId: new Map(), + booklistAddresses: new Set(), + booklistEventIds: new Set(), + myBooklistAddresses: new Set(), + myBooklistEventIds: new Set(), + myCommentAddresses: new Set(), + myCommentEventIds: new Set(), + myHighlightAddresses: new Set(), + myHighlightEventIds: new Set(), commentAddresses: new Set(), highlightAddresses: new Set() } export function useLibraryPublications(isActive: boolean) { - const { pubkey } = useNostr() + const { pubkey, bookmarkListEvent } = useNostr() const [entries, setEntries] = useState([]) const [indexEvents, setIndexEvents] = useState([]) const [engagement, setEngagement] = useState(EMPTY_ENGAGEMENT) @@ -43,8 +55,25 @@ export function useLibraryPublications(isActive: boolean) { const [error, setError] = useState(null) const [allIndexCount, setAllIndexCount] = useState(0) const [topLevelCount, setTopLevelCount] = useState(0) + const [pinListEvent, setPinListEvent] = useState(null) const loadGenRef = useRef(0) + useEffect(() => { + if (!pubkey) { + setPinListEvent(null) + return + } + let cancelled = false + void (async () => { + const relays = await buildLibraryRelayUrls(pubkey) + const pinList = await fetchNewestPinListForPubkey(pubkey, relays) + if (!cancelled) setPinListEvent(pinList) + })() + return () => { + cancelled = true + } + }, [pubkey]) + useEffect(() => { const t = window.setTimeout(() => setDebouncedSearch(searchQuery), SEARCH_DEBOUNCE_MS) return () => window.clearTimeout(t) @@ -69,6 +98,7 @@ export function useLibraryPublications(isActive: boolean) { const result = await Promise.race([ loadLibraryPublicationIndex(relays, { forceRefresh, + viewerPubkey: pubkey || undefined, onIndexesReady: (snapshot) => { if (gen !== loadGenRef.current) return setEntries(snapshot.engaged) @@ -112,6 +142,31 @@ export function useLibraryPublications(isActive: boolean) { void load(false) }, [isActive, load]) + useEffect(() => { + if (!isActive || !pubkey || indexEvents.length === 0) return + let cancelled = false + const onBooklistUpdated = () => { + void (async () => { + const relays = await buildLibraryRelayUrls(pubkey) + const { engagement: nextEngagement, engaged } = await refreshLibraryEngagement( + relays, + indexEvents, + pubkey + ) + if (cancelled) return + setEngagement(nextEngagement) + if (!debouncedSearch.trim()) { + setEntries(engaged) + } + })() + } + window.addEventListener(BOOKLIST_LABEL_UPDATED_EVENT, onBooklistUpdated) + return () => { + cancelled = true + window.removeEventListener(BOOKLIST_LABEL_UPDATED_EVENT, onBooklistUpdated) + } + }, [isActive, pubkey, indexEvents, debouncedSearch]) + useEffect(() => { const q = debouncedSearch.trim() if (!q) { @@ -179,12 +234,32 @@ export function useLibraryPublications(isActive: boolean) { const filteredEntries = useMemo(() => { const q = debouncedSearch.trim() - let list = q ? (searchResults ?? []) : entries - if (showOnlyMine) { - list = filterLibraryPublicationsByUser(list, pubkey) + const mineFilterOpts = { bookmarkListEvent, pinListEvent } + let list: LibraryPublicationEntry[] + if (showOnlyMine && !q) { + list = filterLibraryPublicationsByUser( + libraryPublicationEntriesFromIndex(indexEvents, engagement), + pubkey, + mineFilterOpts + ) + } else { + list = q ? (searchResults ?? []) : entries + if (showOnlyMine) { + list = filterLibraryPublicationsByUser(list, pubkey, mineFilterOpts) + } } return list - }, [entries, showOnlyMine, pubkey, debouncedSearch, searchResults]) + }, [ + entries, + showOnlyMine, + pubkey, + debouncedSearch, + searchResults, + indexEvents, + engagement, + bookmarkListEvent, + pinListEvent + ]) return { entries: filteredEntries, diff --git a/src/hooks/usePublicationBooklist.ts b/src/hooks/usePublicationBooklist.ts new file mode 100644 index 00000000..cfb5de6b --- /dev/null +++ b/src/hooks/usePublicationBooklist.ts @@ -0,0 +1,98 @@ +import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' +import { + dispatchBooklistLabelUpdated, + fetchUserBooklistLabelForPublication, + findSessionBooklistLabelForPublication +} from '@/lib/booklist-label' +import { createBooklistLabelDraftEvent } from '@/lib/draft-event' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNostr } from '@/providers/NostrProvider' +import type { Event } from 'nostr-tools' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +export function usePublicationBooklist(publication: Event) { + const { t } = useTranslation() + const { pubkey, publish, attemptDelete, checkLogin, canManageIdentity } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const [labelEvent, setLabelEvent] = useState(null) + const [loading, setLoading] = useState(false) + const [toggling, setToggling] = useState(false) + + const refresh = useCallback(async () => { + if (!pubkey) { + setLabelEvent(null) + return + } + const session = findSessionBooklistLabelForPublication(pubkey, publication) + if (session) { + setLabelEvent(session) + return + } + setLoading(true) + try { + const relays = await buildAccountListRelayUrlsForMerge({ + accountPubkey: pubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays + }) + const found = await fetchUserBooklistLabelForPublication(pubkey, publication, relays) + setLabelEvent(found) + } finally { + setLoading(false) + } + }, [pubkey, publication, favoriteRelays, blockedRelays]) + + useEffect(() => { + void refresh() + }, [refresh]) + + useEffect(() => { + const onUpdated = (e: globalThis.Event) => { + const detail = (e as CustomEvent<{ publicationId?: string }>).detail + if (detail?.publicationId === publication.id) { + void refresh() + } + } + window.addEventListener('booklist-label-updated', onUpdated as EventListener) + return () => window.removeEventListener('booklist-label-updated', onUpdated as EventListener) + }, [publication.id, refresh]) + + const toggle = useCallback(async () => { + await checkLogin(async () => { + if (toggling) return + setToggling(true) + try { + if (labelEvent) { + await attemptDelete(labelEvent) + setLabelEvent(null) + dispatchBooklistLabelUpdated(publication) + toast.success(t('Removed from my booklist')) + } else { + const draft = createBooklistLabelDraftEvent(publication) + const published = await publish(draft) + setLabelEvent(published) + dispatchBooklistLabelUpdated(publication) + toast.success(t('Added to my booklist')) + } + } catch (err) { + toast.error( + (labelEvent ? t('Remove from my booklist failed') : t('Add to my booklist failed')) + + ': ' + + (err instanceof Error ? err.message : String(err)) + ) + } finally { + setToggling(false) + } + }) + }, [attemptDelete, checkLogin, labelEvent, publication, publish, t, toggling]) + + return { + isOnBooklist: !!labelEvent, + loading, + toggling, + toggle, + canToggle: canManageIdentity + } +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 3b449fdc..96fb7736 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1650,7 +1650,7 @@ export default { Library: 'Bibliothek', 'Library page title': 'Bibliothek', 'Library search placeholder': 'Publikationen nach Titel, Autor, Quelle, Tag oder Abschnitt suchen…', - 'Library show only my publications': 'Nur meine Publikationen', + 'Library show only my publications': 'Meine Publikationen', 'Library empty': 'Noch keine Publikationen auf deinen Relays gefunden.', 'Library empty filtered': 'Keine Publikationen entsprechen den Filtern.', 'Library loading': 'Publikationen werden von Dokument-Relays geladen…', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 24e20822..b84b2591 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1673,7 +1673,7 @@ export default { Library: 'Library', 'Library page title': 'Library', 'Library search placeholder': 'Search publications by title, author, source, tag, or section…', - 'Library show only my publications': 'Show only my publications', + 'Library show only my publications': 'My publications', 'Library empty': 'No publications found on your relays yet.', 'Library empty filtered': 'No publications match your filters.', 'Library loading': 'Loading publications from document relays…', @@ -1683,8 +1683,16 @@ export default { 'Library relay search loading': 'Searching document relays…', 'Library status line': '{{shown}} shown · {{topLevel}} top-level · {{total}} indexes loaded', 'Library badge label': 'Label', + 'Library badge booklist': 'Booklist', + 'Library badge my booklist': 'On my booklist', 'Library badge comment': 'Comment', 'Library badge highlight': 'Highlight', + 'Add to my booklist': 'Add to my booklist', + 'Remove from my booklist': 'Remove from my booklist', + 'Add to my booklist failed': 'Failed to add to booklist', + 'Remove from my booklist failed': 'Failed to remove from booklist', + 'Added to my booklist': 'Added to your booklist', + 'Removed from my booklist': 'Removed from your booklist', 'Publication version': 'v{{version}}', 'Publication sections_one': '{{count}} section', 'Publication sections_other': '{{count}} sections', diff --git a/src/lib/booklist-label.test.ts b/src/lib/booklist-label.test.ts new file mode 100644 index 00000000..024b6843 --- /dev/null +++ b/src/lib/booklist-label.test.ts @@ -0,0 +1,72 @@ +import { ExtendedKind } from '@/constants' +import { createBooklistLabelDraftEvent } from '@/lib/draft-event' +import { + booklistLabelTargetsPublication, + findSessionBooklistLabelForPublication +} from '@/lib/booklist-label' +import { NIP32_BOOKLIST_LABEL, NIP32_UGC_NAMESPACE } from '@/lib/nip32-label' +import { describe, expect, it } from 'vitest' +import type { Event } from 'nostr-tools' + +const PK = 'a'.repeat(64) + +function publicationEvent(d = 'jane-eyre'): Event { + return { + id: '1'.repeat(64), + kind: ExtendedKind.PUBLICATION, + pubkey: PK, + created_at: 100, + content: '', + tags: [['d', d], ['title', 'Jane Eyre'], ['a', `30041:${PK}:intro`]], + sig: 'c'.repeat(128) + } +} + +describe('booklist-label', () => { + it('createBooklistLabelDraftEvent uses ugc namespace and booklist l tag', () => { + const publication = publicationEvent() + const draft = createBooklistLabelDraftEvent(publication) + expect(draft.kind).toBe(ExtendedKind.LABEL) + expect(draft.tags).toContainEqual(['L', NIP32_UGC_NAMESPACE]) + expect(draft.tags).toContainEqual(['l', NIP32_BOOKLIST_LABEL, NIP32_UGC_NAMESPACE]) + expect(draft.tags.some((t) => t[0] === 'a' && t[1] === `30040:${PK}:jane-eyre`)).toBe(true) + }) + + it('booklistLabelTargetsPublication matches a-tag address', () => { + const publication = publicationEvent() + const label: Event = { + id: '2'.repeat(64), + kind: ExtendedKind.LABEL, + pubkey: 'f'.repeat(64), + created_at: 50, + content: '', + tags: [ + ['L', 'ugc'], + ['l', 'booklist', 'ugc'], + ['a', `30040:${PK}:jane-eyre`] + ], + sig: 'e'.repeat(128) + } + expect(booklistLabelTargetsPublication(label, publication)).toBe(true) + }) + + it('findSessionBooklistLabelForPublication reads session-authored labels', () => { + const publication = publicationEvent() + const label: Event = { + id: '3'.repeat(64), + kind: ExtendedKind.LABEL, + pubkey: 'f'.repeat(64), + created_at: 50, + content: '', + tags: [ + ['L', 'ugc'], + ['l', 'booklist', 'ugc'], + ['a', `30040:${PK}:jane-eyre`] + ], + sig: 'e'.repeat(128) + } + // eventService session is empty in unit tests; just verify non-match + expect(findSessionBooklistLabelForPublication('f'.repeat(64), publication)).toBeNull() + expect(booklistLabelTargetsPublication(label, publication)).toBe(true) + }) +}) diff --git a/src/lib/booklist-label.ts b/src/lib/booklist-label.ts new file mode 100644 index 00000000..fa842394 --- /dev/null +++ b/src/lib/booklist-label.ts @@ -0,0 +1,74 @@ +import { ExtendedKind } from '@/constants' +import { eventTagAddress } from '@/lib/publication-index' +import { + labelEventHasBooklistTag, + NIP32_BOOKLIST_LABEL +} from '@/lib/nip32-label' +import client, { eventService } from '@/services/client.service' +import type { Event, Filter } from 'nostr-tools' + +export const BOOKLIST_LABEL_UPDATED_EVENT = 'booklist-label-updated' + +export function dispatchBooklistLabelUpdated(publication: Event): void { + window.dispatchEvent( + new CustomEvent(BOOKLIST_LABEL_UPDATED_EVENT, { + detail: { publicationId: publication.id, address: eventTagAddress(publication) } + }) + ) +} + +export function booklistLabelTargetsPublication(labelEvent: Event, publication: Event): boolean { + if (labelEvent.kind !== ExtendedKind.LABEL || !labelEventHasBooklistTag(labelEvent)) return false + const address = eventTagAddress(publication) + if (!address) return false + for (const tag of labelEvent.tags) { + if (tag[0] === 'a' && tag[1] === address) return true + if (tag[0] === 'e' && tag[1]?.toLowerCase() === publication.id.toLowerCase()) return true + } + return false +} + +function newestMatchingLabel(events: Event[], publication: Event): Event | null { + return ( + events + .filter((ev) => booklistLabelTargetsPublication(ev, publication)) + .sort((a, b) => b.created_at - a.created_at)[0] ?? null + ) +} + +export function findSessionBooklistLabelForPublication( + userPubkey: string, + publication: Event +): Event | null { + const sessionHits = eventService.listSessionEventsAuthoredBy(userPubkey, { + kinds: [ExtendedKind.LABEL], + limit: 48 + }) + return newestMatchingLabel(sessionHits, publication) +} + +export async function fetchUserBooklistLabelForPublication( + userPubkey: string, + publication: Event, + relayUrls: string[] +): Promise { + const session = findSessionBooklistLabelForPublication(userPubkey, publication) + if (session) return session + + const address = eventTagAddress(publication) + if (!address || relayUrls.length === 0) return null + + const filter: Filter = { + kinds: [ExtendedKind.LABEL], + authors: [userPubkey], + '#l': [NIP32_BOOKLIST_LABEL], + '#a': [address], + limit: 8 + } + + const network = await client.fetchEvents(relayUrls, [filter], { + globalTimeout: 10_000, + eoseTimeout: 2_500 + }) + return newestMatchingLabel(network, publication) +} diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index e2312c67..ee268a74 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -5,6 +5,7 @@ import customEmojiService from '@/services/custom-emoji.service' import mediaUpload from '@/services/media-upload.service' import { appendContentWarningTagIfNeeded } from '@/lib/content-warning' import { prefixNostrAddresses } from '@/lib/nostr-address' +import { NIP32_BOOKLIST_LABEL, NIP32_UGC_NAMESPACE } from '@/lib/nip32-label' import { normalizeHashtag, normalizeTopic } from '@/lib/discussion-topics' import logger from '@/lib/logger' import { @@ -1212,6 +1213,20 @@ export function createDeletionRequestDraftEvent(event: Event): TDraftEvent { } } +/** NIP-32 kind-1985 label placing a kind-30040 publication on the user's booklist. */ +export function createBooklistLabelDraftEvent(publication: Event): TDraftEvent { + return { + kind: ExtendedKind.LABEL, + content: '', + tags: [ + ['L', NIP32_UGC_NAMESPACE], + ['l', NIP32_BOOKLIST_LABEL, NIP32_UGC_NAMESPACE], + buildATag(publication) + ], + created_at: dayjs().unix() + } +} + export function createReportDraftEvent(event: Event, reason: string): TDraftEvent { const tags: string[][] = [] if (event.kind === kinds.Metadata) { diff --git a/src/lib/library-publication-index.test.ts b/src/lib/library-publication-index.test.ts index 6f130fdc..e2f65de6 100644 --- a/src/lib/library-publication-index.test.ts +++ b/src/lib/library-publication-index.test.ts @@ -7,6 +7,7 @@ import { clearLibrarySearchSessionCache, filterEngagedPublications, filterLibraryPublicationsBySearch, + filterLibraryPublicationsByUser, pickLibraryPublicationEntries, peekLibrarySearchResults, publicationIndexMatchesSearchQuery, @@ -75,6 +76,7 @@ describe('library-publication-index', () => { expect(engaged).toHaveLength(1) expect(engaged[0].hasLabel).toBe(true) expect(engaged[0].labelNames).toEqual(['MIT']) + expect(engaged[0].hasBooklistLabel).toBe(false) }) it('extracts NIP-32 l tag values, not L namespace declarations', () => { @@ -98,7 +100,33 @@ describe('library-publication-index', () => { const engagement = buildEngagementMapsFromEvents([label], [], []) const engaged = filterEngagedPublications([root], indexByAddress, engagement) expect(engaged).toHaveLength(1) - expect(engaged[0].labelNames).toEqual(['booklist']) + expect(engaged[0].labelNames).toEqual([]) + expect(engaged[0].hasBooklistLabel).toBe(true) + expect(engaged[0].hasMyBooklistLabel).toBe(false) + }) + + it('tracks viewer booklist labels separately', () => { + const rootAddr = `30040:${PK}:book` + const root = indexEvent('book', [`30041:${PK}:intro`]) + const indexByAddress = buildIndexByAddress([root]) + const viewerPk = 'f'.repeat(64) + const label: Event = { + id: '6'.repeat(64), + kind: ExtendedKind.LABEL, + pubkey: viewerPk, + created_at: 50, + content: '', + tags: [ + ['L', 'ugc'], + ['l', 'booklist', 'ugc'], + ['a', rootAddr] + ], + sig: 'e'.repeat(128) + } + const engagement = buildEngagementMapsFromEvents([label], [], [], undefined, undefined, viewerPk) + const engaged = filterEngagedPublications([root], indexByAddress, engagement) + expect(engaged[0].hasBooklistLabel).toBe(true) + expect(engaged[0].hasMyBooklistLabel).toBe(true) }) it('filterLibraryPublicationsBySearch matches title', () => { @@ -108,6 +136,10 @@ describe('library-publication-index', () => { event: root, hasLabel: true, labelNames: ['MIT'], + hasBooklistLabel: false, + hasMyBooklistLabel: false, + hasMyComment: false, + hasMyHighlight: false, hasComment: false, hasHighlight: false, engagementCount: 1 @@ -225,7 +257,85 @@ describe('library-publication-index', () => { ev.created_at = i return ev }) - expect(buildRecentPublicationEntries(roots, 10)).toHaveLength(10) - expect(buildRecentPublicationEntries(roots, 10)[0].event.created_at).toBe(11) + const indexByAddress = buildIndexByAddress(roots) + const engagement = buildEngagementMapsFromEvents([], [], []) + expect(buildRecentPublicationEntries(roots, indexByAddress, engagement, 10)).toHaveLength(10) + expect(buildRecentPublicationEntries(roots, indexByAddress, engagement, 10)[0].event.created_at).toBe(11) + }) + + it('filterLibraryPublicationsByUser includes authored, booklist, bookmarked, and commented', () => { + const viewerPk = 'f'.repeat(64) + const authored = indexEvent('mine', [`30041:${PK}:ch`], '1'.repeat(64)) + authored.pubkey = viewerPk + const booklisted = indexEvent('booklisted', [`30041:${PK}:ch2`], '2'.repeat(64)) + const commented = indexEvent('commented', [`30041:${PK}:ch3`], '3'.repeat(64)) + const unrelated = indexEvent('other', [`30041:${PK}:ch4`], '4'.repeat(64)) + const entries = [ + { + event: authored, + hasLabel: false, + labelNames: [], + hasBooklistLabel: false, + hasMyBooklistLabel: false, + hasMyComment: false, + hasMyHighlight: false, + hasComment: false, + hasHighlight: false, + engagementCount: 0 + }, + { + event: booklisted, + hasLabel: false, + labelNames: [], + hasBooklistLabel: true, + hasMyBooklistLabel: true, + hasMyComment: false, + hasMyHighlight: false, + hasComment: false, + hasHighlight: false, + engagementCount: 0 + }, + { + event: commented, + hasLabel: false, + labelNames: [], + hasBooklistLabel: false, + hasMyBooklistLabel: false, + hasMyComment: true, + hasMyHighlight: false, + hasComment: true, + hasHighlight: false, + engagementCount: 1 + }, + { + event: unrelated, + hasLabel: false, + labelNames: [], + hasBooklistLabel: false, + hasMyBooklistLabel: false, + hasMyComment: false, + hasMyHighlight: false, + hasComment: false, + hasHighlight: false, + engagementCount: 0 + } + ] + const bookmarkList: Event = { + id: 'b'.repeat(64), + kind: kinds.BookmarkList, + pubkey: viewerPk, + created_at: 100, + content: '', + tags: [['a', `30040:${PK}:other`]], + sig: 'd'.repeat(128) + } + unrelated.tags.push(['d', 'other']) + + const filtered = filterLibraryPublicationsByUser(entries, viewerPk, { + bookmarkListEvent: bookmarkList + }) + expect(filtered.map((e) => e.event.id).sort()).toEqual( + [authored.id, booklisted.id, commented.id, unrelated.id].sort() + ) }) }) diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts index 8f3710da..aa1484bf 100644 --- a/src/lib/library-publication-index.ts +++ b/src/lib/library-publication-index.ts @@ -7,7 +7,7 @@ import { } from '@/lib/general-search-text-match' import { normalizeToDTag, parseAdvancedSearch } from '@/lib/search-parser' import logger from '@/lib/logger' -import { extractNip32LabelValues } from '@/lib/nip32-label' +import { extractNip32LabelValues, isBooklistNip32Label } from '@/lib/nip32-label' import { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationSearch } from '@/lib/index-relay-http' import { buildIndexByAddress, @@ -19,6 +19,8 @@ import { getTopLevelIndexEvents, hydrateNestedIndexEvents } from '@/lib/publication-index' +import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' +import { isEventInPinList } from '@/lib/replaceable-list-latest' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { clearLibraryIndexIdbCache, @@ -60,6 +62,14 @@ export type PublicationEngagementMaps = { labelEventIds: Set labelValuesByAddress: Map> labelValuesByEventId: Map> + booklistAddresses: Set + booklistEventIds: Set + myBooklistAddresses: Set + myBooklistEventIds: Set + myCommentAddresses: Set + myCommentEventIds: Set + myHighlightAddresses: Set + myHighlightEventIds: Set commentAddresses: Set highlightAddresses: Set } @@ -69,6 +79,10 @@ export type LibraryPublicationEntry = { hasLabel: boolean /** NIP-32 `l` tag values from kind-1985 events (e.g. "booklist"), not `L` namespaces (e.g. "ugc"). */ labelNames: string[] + hasBooklistLabel: boolean + hasMyBooklistLabel: boolean + hasMyComment: boolean + hasMyHighlight: boolean hasComment: boolean hasHighlight: boolean engagementCount: number @@ -76,6 +90,7 @@ export type LibraryPublicationEntry = { type LibraryIndexCache = { relayKey: string + viewerPubkey: string | null indexEvents: Event[] indexByAddress: Map engagement: PublicationEngagementMaps @@ -302,17 +317,27 @@ export function buildEngagementMapsFromEvents( comments: Event[], highlights: Event[], targetAddresses?: Set, - targetEventIds?: Set + targetEventIds?: Set, + viewerPubkey?: string | null ): PublicationEngagementMaps { const labelAddresses = new Set() const labelEventIds = new Set() const labelValuesByAddress = new Map>() const labelValuesByEventId = new Map>() + const booklistAddresses = new Set() + const booklistEventIds = new Set() + const myBooklistAddresses = new Set() + const myBooklistEventIds = new Set() + const myCommentAddresses = new Set() + const myCommentEventIds = new Set() + const myHighlightAddresses = new Set() + const myHighlightEventIds = new Set() const commentAddresses = new Set() const highlightAddresses = new Set() const addressMatches = (addr: string) => !targetAddresses || targetAddresses.has(addr) const eventIdMatches = (id: string) => !targetEventIds || targetEventIds.has(id.toLowerCase()) + const viewerPk = viewerPubkey?.trim().toLowerCase() const addLabelValues = (map: Map>, key: string, values: string[]) => { if (values.length === 0) return @@ -326,28 +351,52 @@ export function buildEngagementMapsFromEvents( for (const ev of labels) { const labelValues = extractNip32LabelValues(ev.tags) + const isBooklist = labelValues.some(isBooklistNip32Label) + const isViewerLabel = !!viewerPk && ev.pubkey.toLowerCase() === viewerPk for (const tag of ev.tags) { if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) { labelAddresses.add(tag[1]) addLabelValues(labelValuesByAddress, tag[1], labelValues) + if (isBooklist) { + booklistAddresses.add(tag[1]) + if (isViewerLabel) myBooklistAddresses.add(tag[1]) + } } if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) { const eventId = tag[1].toLowerCase() labelEventIds.add(eventId) addLabelValues(labelValuesByEventId, eventId, labelValues) + if (isBooklist) { + booklistEventIds.add(eventId) + if (isViewerLabel) myBooklistEventIds.add(eventId) + } } } } for (const ev of comments) { + const isViewerEvent = !!viewerPk && ev.pubkey.toLowerCase() === viewerPk for (const tag of ev.tags) { - if (tag[0] === 'A' && tag[1] && addressMatches(tag[1])) commentAddresses.add(tag[1]) + if (tag[0] === 'A' && tag[1] && addressMatches(tag[1])) { + commentAddresses.add(tag[1]) + if (isViewerEvent) myCommentAddresses.add(tag[1]) + } + if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1]) && isViewerEvent) { + myCommentEventIds.add(tag[1].toLowerCase()) + } } } for (const ev of highlights) { + const isViewerEvent = !!viewerPk && ev.pubkey.toLowerCase() === viewerPk for (const tag of ev.tags) { - if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) highlightAddresses.add(tag[1]) + if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) { + highlightAddresses.add(tag[1]) + if (isViewerEvent) myHighlightAddresses.add(tag[1]) + } + if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1]) && isViewerEvent) { + myHighlightEventIds.add(tag[1].toLowerCase()) + } } } @@ -356,6 +405,14 @@ export function buildEngagementMapsFromEvents( labelEventIds, labelValuesByAddress, labelValuesByEventId, + booklistAddresses, + booklistEventIds, + myBooklistAddresses, + myBooklistEventIds, + myCommentAddresses, + myCommentEventIds, + myHighlightAddresses, + myHighlightEventIds, commentAddresses, highlightAddresses } @@ -393,23 +450,17 @@ export async function fetchPublicationEngagementMaps( relayUrls: string[], targetAddresses: Set, targetEventIds: Set, - options?: { httpOnly?: boolean } + options?: { httpOnly?: boolean; viewerPubkey?: string | null } ): Promise { if (relayUrls.length === 0 || targetAddresses.size === 0) { - return { - labelAddresses: new Set(), - labelEventIds: new Set(), - labelValuesByAddress: new Map(), - labelValuesByEventId: new Map(), - commentAddresses: new Set(), - highlightAddresses: new Set() - } + return emptyPublicationEngagementMaps() } const addressChunks = chunkArray([...targetAddresses], ENGAGEMENT_ADDRESS_CHUNK) const eventIdChunks = chunkArray([...targetEventIds], ENGAGEMENT_EVENT_ID_CHUNK) const { wsRelays, httpRelays } = splitWsAndHttpRelays(relayUrls) - const useWs = !options?.httpOnly && wsRelays.length > 0 + /** Labels/comments/highlights often live on WS relays only — always query them when available. */ + const useWsEngagement = wsRelays.length > 0 const highlightFilters = addressChunks.map( (chunk): Filter => ({ kinds: [kinds.Highlights], '#a': chunk, limit: chunk.length * 12 }) @@ -425,24 +476,24 @@ export async function fetchPublicationEngagementMaps( ) const highlightPromise = Promise.all([ - useWs && highlightFilters.length > 0 + useWsEngagement && highlightFilters.length > 0 ? queryService.fetchEvents(wsRelays, highlightFilters, QUERY_OPTS) : Promise.resolve([] as Event[]), fetchHttpEngagementByAddresses(httpRelays, kinds.Highlights, '#a', addressChunks) ]).then(([scoped, bulk]) => dedupeEventsById([...scoped, ...bulk])) const labelPromise = Promise.all([ - useWs && labelAddressFilters.length > 0 + useWsEngagement && labelAddressFilters.length > 0 ? queryService.fetchEvents(wsRelays, labelAddressFilters, QUERY_OPTS) : Promise.resolve([] as Event[]), - useWs && labelEventFilters.length > 0 + useWsEngagement && labelEventFilters.length > 0 ? queryService.fetchEvents(wsRelays, labelEventFilters, QUERY_OPTS) : Promise.resolve([] as Event[]), fetchHttpEngagementByAddresses(httpRelays, ExtendedKind.LABEL, '#a', addressChunks) ]).then(([byAddress, byEvent, bulk]) => dedupeEventsById([...byAddress, ...byEvent, ...bulk])) const commentPromise = Promise.all([ - useWs && commentWsFilters.length > 0 + useWsEngagement && commentWsFilters.length > 0 ? queryService.fetchEvents(wsRelays, commentWsFilters, QUERY_OPTS) : Promise.resolve([] as Event[]), fetchHttpEngagementByAddresses(httpRelays, ExtendedKind.COMMENT, '#A', addressChunks) @@ -459,7 +510,8 @@ export async function fetchPublicationEngagementMaps( dedupeEventsById(comments), dedupeEventsById(highlights), targetAddresses, - targetEventIds + targetEventIds, + options?.viewerPubkey ) } @@ -484,95 +536,158 @@ function collectLabelNamesForTarget( ): void { const byAddress = maps.labelValuesByAddress.get(address) if (byAddress) { - for (const value of byAddress) out.add(value) + for (const value of byAddress) { + if (!isBooklistNip32Label(value)) out.add(value) + } } if (eventId) { const byEventId = maps.labelValuesByEventId.get(eventId.toLowerCase()) if (byEventId) { - for (const value of byEventId) out.add(value) + for (const value of byEventId) { + if (!isBooklistNip32Label(value)) out.add(value) + } } } } -export function filterEngagedPublications( - roots: Event[], +function collectBooklistFlagsForTarget( + address: string, + eventId: string | undefined, + maps: PublicationEngagementMaps +): { hasBooklistLabel: boolean; hasMyBooklistLabel: boolean } { + const hasBooklistLabel = + maps.booklistAddresses.has(address) || + (eventId ? maps.booklistEventIds.has(eventId.toLowerCase()) : false) + const hasMyBooklistLabel = + maps.myBooklistAddresses.has(address) || + (eventId ? maps.myBooklistEventIds.has(eventId.toLowerCase()) : false) + return { hasBooklistLabel, hasMyBooklistLabel } +} + +function collectMyEngagementFlagsForTarget( + address: string, + eventId: string | undefined, + maps: PublicationEngagementMaps +): { hasMyComment: boolean; hasMyHighlight: boolean } { + const hasMyComment = + maps.myCommentAddresses.has(address) || + (eventId ? maps.myCommentEventIds.has(eventId.toLowerCase()) : false) + const hasMyHighlight = + maps.myHighlightAddresses.has(address) || + (eventId ? maps.myHighlightEventIds.has(eventId.toLowerCase()) : false) + return { hasMyComment, hasMyHighlight } +} + +/** Build one library row with engagement/booklist flags for a top-level kind-30040 root. */ +export function buildLibraryPublicationEntry( + root: Event, indexByAddress: Map, engagement: PublicationEngagementMaps -): LibraryPublicationEntry[] { - const out: LibraryPublicationEntry[] = [] - - for (const root of roots) { - const reachable = collectReachableAddressesCached(root, indexByAddress) - const rootAddr = eventTagAddress(root) - if (rootAddr) reachable.add(rootAddr) - - let hasLabel = false - let hasComment = false - let hasHighlight = false - let engagementCount = 0 - const labelNames = new Set() - - for (const addr of reachable) { - const indexed = indexByAddress.get(addr) - const flags = addressHasEngagement(addr, indexed?.id, engagement) - if (flags.hasLabel) { - hasLabel = true - collectLabelNamesForTarget(addr, indexed?.id, engagement, labelNames) - } - if (flags.hasComment) hasComment = true - if (flags.hasHighlight) hasHighlight = true - if (flags.hasLabel || flags.hasComment || flags.hasHighlight) engagementCount++ +): LibraryPublicationEntry { + const reachable = collectReachableAddressesCached(root, indexByAddress) + const rootAddr = eventTagAddress(root) + if (rootAddr) reachable.add(rootAddr) + + let hasLabel = false + let hasComment = false + let hasHighlight = false + let hasBooklistLabel = false + let hasMyBooklistLabel = false + let hasMyComment = false + let hasMyHighlight = false + let engagementCount = 0 + const labelNames = new Set() + + for (const addr of reachable) { + const indexed = indexByAddress.get(addr) + const flags = addressHasEngagement(addr, indexed?.id, engagement) + const booklistFlags = collectBooklistFlagsForTarget(addr, indexed?.id, engagement) + const myFlags = collectMyEngagementFlagsForTarget(addr, indexed?.id, engagement) + if (flags.hasLabel) { + hasLabel = true + collectLabelNamesForTarget(addr, indexed?.id, engagement, labelNames) } + if (booklistFlags.hasBooklistLabel) hasBooklistLabel = true + if (booklistFlags.hasMyBooklistLabel) hasMyBooklistLabel = true + if (myFlags.hasMyComment) hasMyComment = true + if (myFlags.hasMyHighlight) hasMyHighlight = true + if (flags.hasComment) hasComment = true + if (flags.hasHighlight) hasHighlight = true + if (flags.hasLabel || flags.hasComment || flags.hasHighlight) engagementCount++ + } - const rootFlags = addressHasEngagement(rootAddr ?? '', root.id, engagement) - hasLabel = hasLabel || rootFlags.hasLabel - hasComment = hasComment || rootFlags.hasComment - hasHighlight = hasHighlight || rootFlags.hasHighlight - if (rootFlags.hasLabel) { - collectLabelNamesForTarget(rootAddr ?? '', root.id, engagement, labelNames) - } + const rootFlags = addressHasEngagement(rootAddr ?? '', root.id, engagement) + const rootBooklistFlags = collectBooklistFlagsForTarget(rootAddr ?? '', root.id, engagement) + const rootMyFlags = collectMyEngagementFlagsForTarget(rootAddr ?? '', root.id, engagement) + hasLabel = hasLabel || rootFlags.hasLabel + hasComment = hasComment || rootFlags.hasComment + hasHighlight = hasHighlight || rootFlags.hasHighlight + hasBooklistLabel = hasBooklistLabel || rootBooklistFlags.hasBooklistLabel + hasMyBooklistLabel = hasMyBooklistLabel || rootBooklistFlags.hasMyBooklistLabel + hasMyComment = hasMyComment || rootMyFlags.hasMyComment + hasMyHighlight = hasMyHighlight || rootMyFlags.hasMyHighlight + if (rootFlags.hasLabel) { + collectLabelNamesForTarget(rootAddr ?? '', root.id, engagement, labelNames) + } - if (hasLabel || hasComment || hasHighlight) { - out.push({ - event: root, - hasLabel, - labelNames: [...labelNames].sort((a, b) => a.localeCompare(b)), - hasComment, - hasHighlight, - engagementCount: Math.max(engagementCount, 1) - }) - } + return { + event: root, + hasLabel, + labelNames: [...labelNames].sort((a, b) => a.localeCompare(b)), + hasBooklistLabel, + hasMyBooklistLabel, + hasMyComment, + hasMyHighlight, + hasComment, + hasHighlight, + engagementCount } +} - return out +export function libraryPublicationEntriesFromIndex( + indexEvents: Event[], + engagement: PublicationEngagementMaps +): LibraryPublicationEntry[] { + const indexByAddress = buildIndexByAddress(indexEvents) + return getTopLevelIndexEvents(indexEvents).map((root) => + buildLibraryPublicationEntry(root, indexByAddress, engagement) + ) +} + +export function filterEngagedPublications( + roots: Event[], + indexByAddress: Map, + engagement: PublicationEngagementMaps +): LibraryPublicationEntry[] { + return getTopLevelIndexEvents(roots) + .map((root) => buildLibraryPublicationEntry(root, indexByAddress, engagement)) + .filter((entry) => entry.hasLabel || entry.hasComment || entry.hasHighlight) } export function buildRecentPublicationEntries( roots: Event[], + indexByAddress: Map, + engagement: PublicationEngagementMaps, limit = LIBRARY_RECENT_FALLBACK_LIMIT ): LibraryPublicationEntry[] { - return [...roots] + return [...getTopLevelIndexEvents(roots)] .sort((a, b) => b.created_at - a.created_at) .slice(0, limit) - .map((event) => ({ - event, - hasLabel: false, - labelNames: [], - hasComment: false, - hasHighlight: false, - engagementCount: 0 - })) + .map((event) => buildLibraryPublicationEntry(event, indexByAddress, engagement)) } -/** Engaged publications first; when none match, show the newest top-level indexes. */ +/** Engaged publications first; when none match, show the newest top-level indexes (still enriched). */ export function pickLibraryPublicationEntries( roots: Event[], indexByAddress: Map, engagement: PublicationEngagementMaps ): LibraryPublicationEntry[] { - const engaged = sortLibraryPublications(filterEngagedPublications(roots, indexByAddress, engagement)) - if (engaged.length > 0) return engaged - return buildRecentPublicationEntries(roots) + const enriched = getTopLevelIndexEvents(roots).map((root) => + buildLibraryPublicationEntry(root, indexByAddress, engagement) + ) + const engaged = enriched.filter((entry) => entry.hasLabel || entry.hasComment || entry.hasHighlight) + if (engaged.length > 0) return sortLibraryPublications(engaged) + return sortLibraryPublications(buildRecentPublicationEntries(roots, indexByAddress, engagement)) } export function sortLibraryPublications(entries: LibraryPublicationEntry[]): LibraryPublicationEntry[] { @@ -583,13 +698,51 @@ export function sortLibraryPublications(entries: LibraryPublicationEntry[]): Lib }) } -const EMPTY_ENGAGEMENT: PublicationEngagementMaps = { - labelAddresses: new Set(), - labelEventIds: new Set(), - labelValuesByAddress: new Map(), - labelValuesByEventId: new Map(), - commentAddresses: new Set(), - highlightAddresses: new Set() +const EMPTY_ENGAGEMENT = emptyPublicationEngagementMaps() + +function emptyPublicationEngagementMaps(): PublicationEngagementMaps { + return { + labelAddresses: new Set(), + labelEventIds: new Set(), + labelValuesByAddress: new Map(), + labelValuesByEventId: new Map(), + booklistAddresses: new Set(), + booklistEventIds: new Set(), + myBooklistAddresses: new Set(), + myBooklistEventIds: new Set(), + myCommentAddresses: new Set(), + myCommentEventIds: new Set(), + myHighlightAddresses: new Set(), + myHighlightEventIds: new Set(), + commentAddresses: new Set(), + highlightAddresses: new Set() + } +} + +function isEventInBookmarkList(bookmarkList: Event, event: Event): boolean { + const isReplaceable = isReplaceableEvent(event.kind) + const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id + return bookmarkList.tags.some((tag) => + isReplaceable ? tag[0] === 'a' && tag[1] === eventKey : tag[0] === 'e' && tag[1] === eventKey + ) +} + +export function publicationEntryBelongsToUser( + entry: LibraryPublicationEntry, + opts: { + userPubkey: string + bookmarkListEvent?: Event | null + pinListEvent?: Event | null + } +): boolean { + const { event } = entry + const pk = opts.userPubkey.toLowerCase() + if (event.pubkey.toLowerCase() === pk) return true + if (event.tags.some((t) => t[0] === 'p' && t[1]?.toLowerCase() === pk)) return true + if (entry.hasMyBooklistLabel || entry.hasMyComment || entry.hasMyHighlight) return true + if (opts.bookmarkListEvent && isEventInBookmarkList(opts.bookmarkListEvent, event)) return true + if (opts.pinListEvent && isEventInPinList(opts.pinListEvent, event)) return true + return false } /** Haystack for kind-30040 index search: general fields plus section refs and language tags. */ @@ -660,6 +813,8 @@ function libraryEntriesFromRoots( event: root, hasLabel: false, labelNames: [], + hasBooklistLabel: false, + hasMyBooklistLabel: false, hasComment: false, hasHighlight: false, engagementCount: 0 @@ -667,6 +822,29 @@ function libraryEntriesFromRoots( }) } +/** Re-fetch engagement maps for the current library index snapshot (e.g. after booklist toggle). */ +export async function refreshLibraryEngagement( + relayUrls: string[], + indexEvents: Event[], + viewerPubkey?: string | null +): Promise<{ engagement: PublicationEngagementMaps; engaged: LibraryPublicationEntry[] }> { + const indexByAddress = buildIndexByAddress(indexEvents) + const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress) + const targetEventIds = collectPublicationIndexEventIds(indexEvents) + const engagement = await fetchPublicationEngagementMaps(relayUrls, targetAddresses, targetEventIds, { + httpOnly: true, + viewerPubkey + }) + const topLevel = getTopLevelIndexEvents(indexEvents) + if (sessionCache) { + sessionCache = { ...sessionCache, engagement, viewerPubkey: viewerPubkey ?? null } + } + return { + engagement, + engaged: pickLibraryPublicationEntries(topLevel, indexByAddress, engagement) + } +} + /** Search all cached kind-30040 indexes (library index store), mapping nested hits to top-level roots. */ export function searchLibraryPublicationIndex( query: string, @@ -1031,14 +1209,20 @@ export function filterLibraryPublicationsBySearch( export function filterLibraryPublicationsByUser( entries: LibraryPublicationEntry[], - userPubkey: string | null | undefined + userPubkey: string | null | undefined, + opts?: { + bookmarkListEvent?: Event | null + pinListEvent?: Event | null + } ): LibraryPublicationEntry[] { if (!userPubkey) return entries - const pk = userPubkey.toLowerCase() - return entries.filter(({ event }) => { - if (event.pubkey.toLowerCase() === pk) return true - return event.tags.some((t) => t[0] === 'p' && t[1]?.toLowerCase() === pk) - }) + return entries.filter((entry) => + publicationEntryBelongsToUser(entry, { + userPubkey, + bookmarkListEvent: opts?.bookmarkListEvent, + pinListEvent: opts?.pinListEvent + }) + ) } function collectTargetAddressesFromIndexes( @@ -1080,6 +1264,7 @@ export async function loadLibraryPublicationIndex( relayUrls: string[], options?: { forceRefresh?: boolean + viewerPubkey?: string | null /** Called as soon as kind-30040 indexes are loaded — before engagement (which can take minutes). */ onIndexesReady?: (snapshot: { engaged: LibraryPublicationEntry[] @@ -1096,11 +1281,29 @@ export async function loadLibraryPublicationIndex( engagement: PublicationEngagementMaps }> { const key = relaySetKey(relayUrls) + const viewerPubkey = options?.viewerPubkey ?? null if (import.meta.env.DEV) { logger.info('[Library] load start', { relayCount: relayUrls.length, cached: sessionCache?.relayKey === key }) } if (!options?.forceRefresh && sessionCache?.relayKey === key) { + if (sessionCache.viewerPubkey !== viewerPubkey) { + const targetAddresses = collectTargetAddressesFromIndexes( + sessionCache.indexEvents, + sessionCache.indexByAddress + ) + const targetEventIds = collectPublicationIndexEventIds(sessionCache.indexEvents) + sessionCache = { + ...sessionCache, + viewerPubkey, + engagement: await fetchPublicationEngagementMaps( + relayUrls, + targetAddresses, + targetEventIds, + { httpOnly: true, viewerPubkey } + ) + } + } const engaged = await buildEngagedFromCache( relayUrls, sessionCache.indexEvents, @@ -1128,7 +1331,7 @@ export async function loadLibraryPublicationIndex( let topLevel = getTopLevelIndexEvents(indexEvents) options?.onIndexesReady?.({ - engaged: buildRecentPublicationEntries(topLevel), + engaged: buildRecentPublicationEntries(topLevel, indexByAddress, emptyPublicationEngagementMaps()), allIndexCount: indexEvents.length, topLevelCount: topLevel.length, indexEvents @@ -1158,21 +1361,11 @@ export async function loadLibraryPublicationIndex( try { engagement = await Promise.race([ fetchPublicationEngagementMaps(relayUrls, targetAddresses, targetEventIds, { - httpOnly: true + httpOnly: true, + viewerPubkey }), new Promise((resolve) => { - window.setTimeout( - () => - resolve({ - labelAddresses: new Set(), - labelEventIds: new Set(), - labelValuesByAddress: new Map(), - labelValuesByEventId: new Map(), - commentAddresses: new Set(), - highlightAddresses: new Set() - }), - ENGAGEMENT_FETCH_TIMEOUT_MS - ) + window.setTimeout(() => resolve(emptyPublicationEngagementMaps()), ENGAGEMENT_FETCH_TIMEOUT_MS) }) ]) } catch (e) { @@ -1181,14 +1374,7 @@ export async function loadLibraryPublicationIndex( message: e instanceof Error ? e.message : String(e) }) } - engagement = { - labelAddresses: new Set(), - labelEventIds: new Set(), - labelValuesByAddress: new Map(), - labelValuesByEventId: new Map(), - commentAddresses: new Set(), - highlightAddresses: new Set() - } + engagement = emptyPublicationEngagementMaps() } if (import.meta.env.DEV) { logger.info('[Library] engagement maps built', { @@ -1198,7 +1384,7 @@ export async function loadLibraryPublicationIndex( }) } - sessionCache = { relayKey: key, indexEvents, indexByAddress, engagement } + sessionCache = { relayKey: key, viewerPubkey, indexEvents, indexByAddress, engagement } const engaged = pickLibraryPublicationEntries(topLevel, indexByAddress, engagement) diff --git a/src/lib/nip32-label.test.ts b/src/lib/nip32-label.test.ts index d3c9f301..cc168a93 100644 --- a/src/lib/nip32-label.test.ts +++ b/src/lib/nip32-label.test.ts @@ -1,8 +1,18 @@ import { describe, expect, it } from 'vitest' -import { extractNip32LabelValues, formatNip32LabelSnippet } from '@/lib/nip32-label' +import { + extractNip32LabelValues, + formatNip32LabelSnippet, + isBooklistNip32Label +} from '@/lib/nip32-label' import type { Event } from 'nostr-tools' describe('nip32-label', () => { + it('isBooklistNip32Label matches case-insensitively', () => { + expect(isBooklistNip32Label('booklist')).toBe(true) + expect(isBooklistNip32Label('Booklist')).toBe(true) + expect(isBooklistNip32Label('ugc')).toBe(false) + }) + it('extracts lowercase l tag values, not uppercase L namespace declarations', () => { const tags = [ ['L', 'ugc'], diff --git a/src/lib/nip32-label.ts b/src/lib/nip32-label.ts index 55a4796b..2fbd4842 100644 --- a/src/lib/nip32-label.ts +++ b/src/lib/nip32-label.ts @@ -1,5 +1,19 @@ import type { Event } from 'nostr-tools' +/** NIP-32 `l` tag value for user-curated publication lists (namespace `ugc`). */ +export const NIP32_BOOKLIST_LABEL = 'booklist' + +/** NIP-32 namespace for user-generated labels (e.g. booklist). */ +export const NIP32_UGC_NAMESPACE = 'ugc' + +export function isBooklistNip32Label(label: string): boolean { + return label.trim().toLowerCase() === NIP32_BOOKLIST_LABEL +} + +export function labelEventHasBooklistTag(event: Pick): boolean { + return extractNip32LabelValues(event.tags).some(isBooklistNip32Label) +} + /** NIP-32 lowercase `l` tag values (actual labels), not uppercase `L` namespace declarations. */ export function extractNip32LabelValues(tags: string[][]): string[] { const out: string[] = []