Browse Source

label books

imwald
Silberengel 1 week ago
parent
commit
31dfae821c
  1. 51
      src/components/Library/LibraryPublicationGrid.tsx
  2. 54
      src/components/Note/PublicationBooklistButton.tsx
  3. 9
      src/components/Note/PublicationIndexMetadata.tsx
  4. 83
      src/hooks/useLibraryPublications.ts
  5. 98
      src/hooks/usePublicationBooklist.ts
  6. 2
      src/i18n/locales/de.ts
  7. 10
      src/i18n/locales/en.ts
  8. 72
      src/lib/booklist-label.test.ts
  9. 74
      src/lib/booklist-label.ts
  10. 15
      src/lib/draft-event.ts
  11. 116
      src/lib/library-publication-index.test.ts
  12. 334
      src/lib/library-publication-index.ts
  13. 12
      src/lib/nip32-label.test.ts
  14. 14
      src/lib/nip32-label.ts

51
src/components/Library/LibraryPublicationGrid.tsx

@ -1,32 +1,73 @@ @@ -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 <BookOpen className="size-3" aria-hidden />
}
return <Tag className="size-3" aria-hidden />
}
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 (
<div className="flex flex-wrap gap-2 px-1 pb-2">
{entry.hasBooklistLabel ? (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs',
entry.hasMyBooklistLabel
? 'bg-green-500/15 text-green-700 ring-1 ring-green-600/30 dark:bg-green-500/20 dark:text-green-400 dark:ring-green-500/40'
: 'bg-muted text-muted-foreground'
)}
title={
entry.hasMyBooklistLabel
? t('Library badge my booklist')
: t('Library badge booklist')
}
>
<BookOpen className="size-3" aria-hidden />
<span className="sr-only">
{entry.hasMyBooklistLabel
? t('Library badge my booklist')
: t('Library badge booklist')}
</span>
</span>
) : null}
{entry.hasLabel &&
(entry.labelNames.length > 0 ? (
entry.labelNames.map((name) => (
(otherLabels.length > 0 ? (
otherLabels.map((name) => (
<span
key={name}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
<Tag className="size-3" aria-hidden />
<LabelBadgeIcon name={name} />
{name}
</span>
))
) : (
!entry.hasBooklistLabel ? (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
<Tag className="size-3" aria-hidden />
{t('Library badge label')}
</span>
) : null
))}
{entry.hasComment && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">

54
src/components/Note/PublicationBooklistButton.tsx

@ -0,0 +1,54 @@ @@ -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 (
<Button
type="button"
variant="outline"
size="sm"
className={cn(
'gap-2',
isOnBooklist
? [
'border-border bg-background text-foreground shadow-none',
'hover:border-border hover:bg-muted hover:text-foreground',
'dark:border-border dark:bg-background dark:text-foreground',
'dark:hover:border-border dark:hover:bg-muted dark:hover:text-foreground'
]
: [
'border-green-600 bg-green-600 text-white shadow-sm',
'hover:border-green-700 hover:bg-green-700 hover:text-white',
'dark:border-green-500 dark:bg-green-600 dark:text-white',
'dark:hover:border-green-400 dark:hover:bg-green-500 dark:hover:text-white',
'focus-visible:ring-green-600/40 dark:focus-visible:ring-green-500/40'
],
className
)}
disabled={loading || toggling}
onClick={() => void toggle()}
>
{loading || toggling ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<BookOpen className="size-4" aria-hidden />
)}
{isOnBooklist ? t('Remove from my booklist') : t('Add to my booklist')}
</Button>
)
}

9
src/components/Note/PublicationIndexMetadata.tsx

@ -13,6 +13,7 @@ import { useMemo } from 'react' @@ -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,13 +177,15 @@ export default function PublicationIndexMetadata({ @@ -176,13 +177,15 @@ export default function PublicationIndexMetadata({
</div>
) : null}
{metadata.source || tagsComponent || isFull ? (
<div className="flex min-w-0 flex-col gap-2">
{metadata.source ? (
<a
href={metadata.source}
target="_blank"
rel="noopener noreferrer"
className={cn(
'inline-flex min-w-0 max-w-full items-center gap-1.5 text-primary hover:underline',
'flex min-w-0 max-w-full items-center gap-1.5 text-primary hover:underline',
isFull ? 'text-sm' : 'text-xs'
)}
onClick={(e) => e.stopPropagation()}
@ -194,6 +197,10 @@ export default function PublicationIndexMetadata({ @@ -194,6 +197,10 @@ export default function PublicationIndexMetadata({
{tagsComponent}
{isFull ? <PublicationBooklistButton event={event} className="w-fit self-start" /> : null}
</div>
) : null}
{isFull && metadata.sections.length > 0 ? (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-foreground">

83
src/hooks/useLibraryPublications.ts

@ -2,13 +2,17 @@ import { @@ -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 = { @@ -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<LibraryPublicationEntry[]>([])
const [indexEvents, setIndexEvents] = useState<Event[]>([])
const [engagement, setEngagement] = useState<PublicationEngagementMaps>(EMPTY_ENGAGEMENT)
@ -43,8 +55,25 @@ export function useLibraryPublications(isActive: boolean) { @@ -43,8 +55,25 @@ export function useLibraryPublications(isActive: boolean) {
const [error, setError] = useState<string | null>(null)
const [allIndexCount, setAllIndexCount] = useState(0)
const [topLevelCount, setTopLevelCount] = useState(0)
const [pinListEvent, setPinListEvent] = useState<Event | null>(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) { @@ -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) { @@ -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) { @@ -179,12 +234,32 @@ export function useLibraryPublications(isActive: boolean) {
const filteredEntries = useMemo(() => {
const q = debouncedSearch.trim()
let list = q ? (searchResults ?? []) : entries
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)
list = filterLibraryPublicationsByUser(list, pubkey, mineFilterOpts)
}
}
return list
}, [entries, showOnlyMine, pubkey, debouncedSearch, searchResults])
}, [
entries,
showOnlyMine,
pubkey,
debouncedSearch,
searchResults,
indexEvents,
engagement,
bookmarkListEvent,
pinListEvent
])
return {
entries: filteredEntries,

98
src/hooks/usePublicationBooklist.ts

@ -0,0 +1,98 @@ @@ -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<Event | null>(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
}
}

2
src/i18n/locales/de.ts

@ -1650,7 +1650,7 @@ export default { @@ -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…',

10
src/i18n/locales/en.ts

@ -1673,7 +1673,7 @@ export default { @@ -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 { @@ -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',

72
src/lib/booklist-label.test.ts

@ -0,0 +1,72 @@ @@ -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)
})
})

74
src/lib/booklist-label.ts

@ -0,0 +1,74 @@ @@ -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<Event | null> {
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)
}

15
src/lib/draft-event.ts

@ -5,6 +5,7 @@ import customEmojiService from '@/services/custom-emoji.service' @@ -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 { @@ -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) {

116
src/lib/library-publication-index.test.ts

@ -7,6 +7,7 @@ import { @@ -7,6 +7,7 @@ import {
clearLibrarySearchSessionCache,
filterEngagedPublications,
filterLibraryPublicationsBySearch,
filterLibraryPublicationsByUser,
pickLibraryPublicationEntries,
peekLibrarySearchResults,
publicationIndexMatchesSearchQuery,
@ -75,6 +76,7 @@ describe('library-publication-index', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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()
)
})
})

334
src/lib/library-publication-index.ts

@ -7,7 +7,7 @@ import { @@ -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 { @@ -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 = { @@ -60,6 +62,14 @@ export type PublicationEngagementMaps = {
labelEventIds: Set<string>
labelValuesByAddress: Map<string, Set<string>>
labelValuesByEventId: Map<string, Set<string>>
booklistAddresses: Set<string>
booklistEventIds: Set<string>
myBooklistAddresses: Set<string>
myBooklistEventIds: Set<string>
myCommentAddresses: Set<string>
myCommentEventIds: Set<string>
myHighlightAddresses: Set<string>
myHighlightEventIds: Set<string>
commentAddresses: Set<string>
highlightAddresses: Set<string>
}
@ -69,6 +79,10 @@ export type LibraryPublicationEntry = { @@ -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 = { @@ -76,6 +90,7 @@ export type LibraryPublicationEntry = {
type LibraryIndexCache = {
relayKey: string
viewerPubkey: string | null
indexEvents: Event[]
indexByAddress: Map<string, Event>
engagement: PublicationEngagementMaps
@ -302,17 +317,27 @@ export function buildEngagementMapsFromEvents( @@ -302,17 +317,27 @@ export function buildEngagementMapsFromEvents(
comments: Event[],
highlights: Event[],
targetAddresses?: Set<string>,
targetEventIds?: Set<string>
targetEventIds?: Set<string>,
viewerPubkey?: string | null
): PublicationEngagementMaps {
const labelAddresses = new Set<string>()
const labelEventIds = new Set<string>()
const labelValuesByAddress = new Map<string, Set<string>>()
const labelValuesByEventId = new Map<string, Set<string>>()
const booklistAddresses = new Set<string>()
const booklistEventIds = new Set<string>()
const myBooklistAddresses = new Set<string>()
const myBooklistEventIds = new Set<string>()
const myCommentAddresses = new Set<string>()
const myCommentEventIds = new Set<string>()
const myHighlightAddresses = new Set<string>()
const myHighlightEventIds = new Set<string>()
const commentAddresses = new Set<string>()
const highlightAddresses = new Set<string>()
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<string, Set<string>>, key: string, values: string[]) => {
if (values.length === 0) return
@ -326,28 +351,52 @@ export function buildEngagementMapsFromEvents( @@ -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( @@ -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( @@ -393,23 +450,17 @@ export async function fetchPublicationEngagementMaps(
relayUrls: string[],
targetAddresses: Set<string>,
targetEventIds: Set<string>,
options?: { httpOnly?: boolean }
options?: { httpOnly?: boolean; viewerPubkey?: string | null }
): Promise<PublicationEngagementMaps> {
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( @@ -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( @@ -459,7 +510,8 @@ export async function fetchPublicationEngagementMaps(
dedupeEventsById(comments),
dedupeEventsById(highlights),
targetAddresses,
targetEventIds
targetEventIds,
options?.viewerPubkey
)
}
@ -484,24 +536,54 @@ function collectLabelNamesForTarget( @@ -484,24 +536,54 @@ 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<string, Event>,
engagement: PublicationEngagementMaps
): LibraryPublicationEntry[] {
const out: LibraryPublicationEntry[] = []
for (const root of roots) {
): LibraryPublicationEntry {
const reachable = collectReachableAddressesCached(root, indexByAddress)
const rootAddr = eventTagAddress(root)
if (rootAddr) reachable.add(rootAddr)
@ -509,70 +591,103 @@ export function filterEngagedPublications( @@ -509,70 +591,103 @@ export function filterEngagedPublications(
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<string>()
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)
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({
return {
event: root,
hasLabel,
labelNames: [...labelNames].sort((a, b) => a.localeCompare(b)),
hasBooklistLabel,
hasMyBooklistLabel,
hasMyComment,
hasMyHighlight,
hasComment,
hasHighlight,
engagementCount: Math.max(engagementCount, 1)
})
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<string, Event>,
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<string, Event>,
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<string, Event>,
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,14 +698,52 @@ export function sortLibraryPublications(entries: LibraryPublicationEntry[]): Lib @@ -583,14 +698,52 @@ export function sortLibraryPublications(entries: LibraryPublicationEntry[]): Lib
})
}
const EMPTY_ENGAGEMENT: PublicationEngagementMaps = {
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. */
export function publicationIndexSearchHaystack(event: Event): string {
@ -660,6 +813,8 @@ function libraryEntriesFromRoots( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -1158,21 +1361,11 @@ export async function loadLibraryPublicationIndex(
try {
engagement = await Promise.race([
fetchPublicationEngagementMaps(relayUrls, targetAddresses, targetEventIds, {
httpOnly: true
httpOnly: true,
viewerPubkey
}),
new Promise<PublicationEngagementMaps>((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( @@ -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( @@ -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)

12
src/lib/nip32-label.test.ts

@ -1,8 +1,18 @@ @@ -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'],

14
src/lib/nip32-label.ts

@ -1,5 +1,19 @@ @@ -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<Event, 'tags'>): 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[] = []

Loading…
Cancel
Save