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[] = []