diff --git a/src/components/Library/LibraryPublicationGrid.tsx b/src/components/Library/LibraryPublicationGrid.tsx index e3c67949..af8cb86e 100644 --- a/src/components/Library/LibraryPublicationGrid.tsx +++ b/src/components/Library/LibraryPublicationGrid.tsx @@ -5,7 +5,7 @@ import type { LibraryPublicationEntry } from '@/lib/library-publication-index' import { isBooklistNip32Label } from '@/lib/nip32-label' import { cn } from '@/lib/utils' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { BookOpen, Highlighter, MessageSquare, Tag } from 'lucide-react' +import { BookOpen, Bookmark, Highlighter, MessageSquare, Pin, Tag } from 'lucide-react' import { useTranslation } from 'react-i18next' function LabelBadgeIcon({ name }: { name: string }) { @@ -23,7 +23,9 @@ function EngagementBadges({ entry }: { entry: LibraryPublicationEntry }) { otherLabels.length === 0 && !entry.hasLabel && !entry.hasComment && - !entry.hasHighlight + !entry.hasHighlight && + !entry.hasBookmark && + !entry.hasPin ) { return null } @@ -83,6 +85,18 @@ function EngagementBadges({ entry }: { entry: LibraryPublicationEntry }) { {t('Library badge highlight')} )} + {entry.hasBookmark && ( + + + {t('Library badge bookmark')} + + )} + {entry.hasPin && ( + + + {t('Library badge pin')} + + )} ) } diff --git a/src/hooks/useLibraryPublications.ts b/src/hooks/useLibraryPublications.ts index 09c70b35..4a9e2cc1 100644 --- a/src/hooks/useLibraryPublications.ts +++ b/src/hooks/useLibraryPublications.ts @@ -40,7 +40,13 @@ const EMPTY_ENGAGEMENT: PublicationEngagementMaps = { myHighlightAddresses: new Set(), myHighlightEventIds: new Set(), commentAddresses: new Set(), - highlightAddresses: new Set() + commentEventIds: new Set(), + highlightAddresses: new Set(), + highlightEventIds: new Set(), + bookmarkAddresses: new Set(), + bookmarkEventIds: new Set(), + pinAddresses: new Set(), + pinEventIds: new Set() } const EMPTY_BOOKLIST_TARGETS = { addresses: new Set(), eventIds: new Set() } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 5349e0db..c4c70a7b 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1664,6 +1664,8 @@ export default { 'Library badge label': 'Label', 'Library badge comment': 'Kommentar', 'Library badge highlight': 'Markierung', + 'Library badge bookmark': 'Lesezeichen', + 'Library badge pin': 'Angepinnt', 'Publication version': 'v{{version}}', 'Publication sections_one': '{{count}} Abschnitt', 'Publication sections_other': '{{count}} Abschnitte', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9abe6aea..11409506 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1689,6 +1689,8 @@ export default { 'Library badge my booklist': 'On my booklist', 'Library badge comment': 'Comment', 'Library badge highlight': 'Highlight', + 'Library badge bookmark': 'Bookmark', + 'Library badge pin': 'Pin', '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', diff --git a/src/lib/library-publication-index.test.ts b/src/lib/library-publication-index.test.ts index e0c7fdc7..dbe8329f 100644 --- a/src/lib/library-publication-index.test.ts +++ b/src/lib/library-publication-index.test.ts @@ -39,6 +39,65 @@ function indexEvent(d: string, aTags: string[], id = d.padEnd(64, '0').slice(0, } describe('library-publication-index', () => { + it('matches comments and highlights by root event id', () => { + const root = indexEvent('book', [`30041:${PK}:intro`]) + const indexByAddress = buildIndexByAddress([root]) + const comment: Event = { + id: '8'.repeat(64), + kind: ExtendedKind.COMMENT, + pubkey: 'f'.repeat(64), + created_at: 50, + content: 'nice book', + tags: [['e', root.id]], + sig: 'e'.repeat(128) + } + const highlight: Event = { + id: '9'.repeat(64), + kind: kinds.Highlights, + pubkey: 'f'.repeat(64), + created_at: 50, + content: 'quote', + tags: [['e', root.id]], + sig: 'e'.repeat(128) + } + const engagement = buildEngagementMapsFromEvents([], [comment], [highlight]) + const engaged = filterEngagedPublications([root], indexByAddress, engagement) + expect(engaged).toHaveLength(1) + expect(engaged[0].hasComment).toBe(true) + expect(engaged[0].hasHighlight).toBe(true) + }) + + it('matches bookmark and pin lists from any author', () => { + const rootAddr = `30040:${PK}:book` + const root = indexEvent('book', [`30041:${PK}:intro`]) + const indexByAddress = buildIndexByAddress([root]) + const bookmarkList: Event = { + id: 'b'.repeat(64), + kind: kinds.BookmarkList, + pubkey: 'f'.repeat(64), + created_at: 100, + content: '', + tags: [['a', rootAddr]], + sig: 'd'.repeat(128) + } + const pinList: Event = { + id: 'p'.repeat(64), + kind: 10001, + pubkey: 'e'.repeat(64), + created_at: 100, + content: '', + tags: [['e', root.id]], + sig: 'd'.repeat(128) + } + const engagement = buildEngagementMapsFromEvents([], [], [], undefined, undefined, null, [ + bookmarkList + ], [pinList]) + const engaged = filterEngagedPublications([root], indexByAddress, engagement) + expect(engaged).toHaveLength(1) + expect(engaged[0].hasBookmark).toBe(true) + expect(engaged[0].hasPin).toBe(true) + }) + it('matches engagement on nested 30041 addresses', () => { const leafAddr = `30041:${PK}:chapter-1` const childAddr = `30040:${PK}:part-1` @@ -147,6 +206,8 @@ describe('library-publication-index', () => { hasMyHighlight: false, hasComment: false, hasHighlight: false, + hasBookmark: false, + hasPin: false, engagementCount: 1 } ] @@ -358,6 +419,8 @@ describe('library-publication-index', () => { hasMyHighlight: false, hasComment: false, hasHighlight: false, + hasBookmark: false, + hasPin: false, engagementCount: 0 }, { @@ -370,6 +433,8 @@ describe('library-publication-index', () => { hasMyHighlight: false, hasComment: false, hasHighlight: false, + hasBookmark: false, + hasPin: false, engagementCount: 0 }, { @@ -382,6 +447,8 @@ describe('library-publication-index', () => { hasMyHighlight: false, hasComment: true, hasHighlight: false, + hasBookmark: false, + hasPin: false, engagementCount: 1 }, { @@ -394,6 +461,8 @@ describe('library-publication-index', () => { hasMyHighlight: false, hasComment: false, hasHighlight: false, + hasBookmark: false, + hasPin: false, engagementCount: 0 } ] @@ -479,6 +548,8 @@ describe('library-publication-index', () => { hasMyHighlight: false, hasComment: false, hasHighlight: false, + hasBookmark: false, + hasPin: false, engagementCount: 0 } const filtered = filterLibraryPublicationsByUser([entry], viewerPk, { diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts index 42ee7ea2..fb82c7dc 100644 --- a/src/lib/library-publication-index.ts +++ b/src/lib/library-publication-index.ts @@ -54,6 +54,8 @@ const ENGAGEMENT_FETCH_TIMEOUT_MS = 25_000 const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200 export const LIBRARY_RELAY_SEARCH_LIMIT = 100 const LIBRARY_RELAY_SEARCH_TIMEOUT_MS = 28_000 +/** NIP-51 pin list (kind 10001). */ +const PIN_LIST_KIND = 10001 const QUERY_OPTS = { globalTimeout: 18_000, eoseTimeout: 3_000, @@ -74,7 +76,13 @@ export type PublicationEngagementMaps = { myHighlightAddresses: Set myHighlightEventIds: Set commentAddresses: Set + commentEventIds: Set highlightAddresses: Set + highlightEventIds: Set + bookmarkAddresses: Set + bookmarkEventIds: Set + pinAddresses: Set + pinEventIds: Set } export type LibraryPublicationEntry = { @@ -88,6 +96,8 @@ export type LibraryPublicationEntry = { hasMyHighlight: boolean hasComment: boolean hasHighlight: boolean + hasBookmark: boolean + hasPin: boolean engagementCount: number } @@ -120,9 +130,15 @@ function librarySearchFingerprint(context: LibrarySearchContext): string { ? engagement.labelAddresses.size + engagement.labelEventIds.size + engagement.commentAddresses.size + + engagement.commentEventIds.size + engagement.highlightAddresses.size + + engagement.highlightEventIds.size + engagement.booklistAddresses.size + engagement.booklistEventIds.size + + engagement.bookmarkAddresses.size + + engagement.bookmarkEventIds.size + + engagement.pinAddresses.size + + engagement.pinEventIds.size + engagement.myBooklistAddresses.size + engagement.myBooklistEventIds.size + engagement.myCommentAddresses.size + @@ -344,7 +360,9 @@ export function buildEngagementMapsFromEvents( highlights: Event[], targetAddresses?: Set, targetEventIds?: Set, - viewerPubkey?: string | null + viewerPubkey?: string | null, + bookmarkLists: Event[] = [], + pinLists: Event[] = [] ): PublicationEngagementMaps { const labelAddresses = new Set() const labelEventIds = new Set() @@ -359,7 +377,13 @@ export function buildEngagementMapsFromEvents( const myHighlightAddresses = new Set() const myHighlightEventIds = new Set() const commentAddresses = new Set() + const commentEventIds = new Set() const highlightAddresses = new Set() + const highlightEventIds = new Set() + const bookmarkAddresses = new Set() + const bookmarkEventIds = new Set() + const pinAddresses = new Set() + const pinEventIds = new Set() const addressMatches = (addr: string) => !targetAddresses || targetAddresses.has(addr) const eventIdMatches = (id: string) => !targetEventIds || targetEventIds.has(id.toLowerCase()) @@ -407,8 +431,9 @@ export function buildEngagementMapsFromEvents( 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()) + if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) { + commentEventIds.add(tag[1].toLowerCase()) + if (isViewerEvent) myCommentEventIds.add(tag[1].toLowerCase()) } } } @@ -420,8 +445,31 @@ export function buildEngagementMapsFromEvents( 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()) + if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) { + highlightEventIds.add(tag[1].toLowerCase()) + if (isViewerEvent) myHighlightEventIds.add(tag[1].toLowerCase()) + } + } + } + + for (const ev of bookmarkLists) { + for (const tag of ev.tags) { + if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) { + bookmarkAddresses.add(tag[1]) + } + if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) { + bookmarkEventIds.add(tag[1].toLowerCase()) + } + } + } + + for (const ev of pinLists) { + for (const tag of ev.tags) { + if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) { + pinAddresses.add(tag[1]) + } + if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) { + pinEventIds.add(tag[1].toLowerCase()) } } } @@ -440,7 +488,13 @@ export function buildEngagementMapsFromEvents( myHighlightAddresses, myHighlightEventIds, commentAddresses, - highlightAddresses + commentEventIds, + highlightAddresses, + highlightEventIds, + bookmarkAddresses, + bookmarkEventIds, + pinAddresses, + pinEventIds } } @@ -500,13 +554,34 @@ export async function fetchPublicationEngagementMaps( const commentWsFilters = addressChunks.map( (chunk): Filter => ({ kinds: [ExtendedKind.COMMENT], '#A': chunk, limit: chunk.length * 12 }) ) + const commentEventFilters = eventIdChunks.map( + (chunk): Filter => ({ kinds: [ExtendedKind.COMMENT], '#e': chunk, limit: chunk.length * 12 }) + ) + const highlightEventFilters = eventIdChunks.map( + (chunk): Filter => ({ kinds: [kinds.Highlights], '#e': chunk, limit: chunk.length * 12 }) + ) + const bookmarkAddressFilters = addressChunks.map( + (chunk): Filter => ({ kinds: [kinds.BookmarkList], '#a': chunk, limit: chunk.length * 8 }) + ) + const bookmarkEventFilters = eventIdChunks.map( + (chunk): Filter => ({ kinds: [kinds.BookmarkList], '#e': chunk, limit: chunk.length * 8 }) + ) + const pinAddressFilters = addressChunks.map( + (chunk): Filter => ({ kinds: [PIN_LIST_KIND], '#a': chunk, limit: chunk.length * 8 }) + ) + const pinEventFilters = eventIdChunks.map( + (chunk): Filter => ({ kinds: [PIN_LIST_KIND], '#e': chunk, limit: chunk.length * 8 }) + ) const highlightPromise = Promise.all([ useWsEngagement && highlightFilters.length > 0 ? queryService.fetchEvents(wsRelays, highlightFilters, QUERY_OPTS) : Promise.resolve([] as Event[]), + useWsEngagement && highlightEventFilters.length > 0 + ? queryService.fetchEvents(wsRelays, highlightEventFilters, QUERY_OPTS) + : Promise.resolve([] as Event[]), fetchHttpEngagementByAddresses(httpRelays, kinds.Highlights, '#a', addressChunks) - ]).then(([scoped, bulk]) => dedupeEventsById([...scoped, ...bulk])) + ]).then(([scoped, byEvent, bulk]) => dedupeEventsById([...scoped, ...byEvent, ...bulk])) const labelPromise = Promise.all([ useWsEngagement && labelAddressFilters.length > 0 @@ -522,13 +597,38 @@ export async function fetchPublicationEngagementMaps( useWsEngagement && commentWsFilters.length > 0 ? queryService.fetchEvents(wsRelays, commentWsFilters, QUERY_OPTS) : Promise.resolve([] as Event[]), + useWsEngagement && commentEventFilters.length > 0 + ? queryService.fetchEvents(wsRelays, commentEventFilters, QUERY_OPTS) + : Promise.resolve([] as Event[]), fetchHttpEngagementByAddresses(httpRelays, ExtendedKind.COMMENT, '#A', addressChunks) - ]).then(([scoped, bulk]) => dedupeEventsById([...scoped, ...bulk])) + ]).then(([scoped, byEvent, bulk]) => dedupeEventsById([...scoped, ...byEvent, ...bulk])) + + const bookmarkPromise = Promise.all([ + useWsEngagement && bookmarkAddressFilters.length > 0 + ? queryService.fetchEvents(wsRelays, bookmarkAddressFilters, QUERY_OPTS) + : Promise.resolve([] as Event[]), + useWsEngagement && bookmarkEventFilters.length > 0 + ? queryService.fetchEvents(wsRelays, bookmarkEventFilters, QUERY_OPTS) + : Promise.resolve([] as Event[]), + fetchHttpEngagementByAddresses(httpRelays, kinds.BookmarkList, '#a', addressChunks) + ]).then(([byAddress, byEvent, bulk]) => dedupeEventsById([...byAddress, ...byEvent, ...bulk])) - const [highlights, labels, comments] = await Promise.all([ + const pinPromise = Promise.all([ + useWsEngagement && pinAddressFilters.length > 0 + ? queryService.fetchEvents(wsRelays, pinAddressFilters, QUERY_OPTS) + : Promise.resolve([] as Event[]), + useWsEngagement && pinEventFilters.length > 0 + ? queryService.fetchEvents(wsRelays, pinEventFilters, QUERY_OPTS) + : Promise.resolve([] as Event[]), + fetchHttpEngagementByAddresses(httpRelays, PIN_LIST_KIND, '#a', addressChunks) + ]).then(([byAddress, byEvent, bulk]) => dedupeEventsById([...byAddress, ...byEvent, ...bulk])) + + const [highlights, labels, comments, bookmarkLists, pinLists] = await Promise.all([ highlightPromise, labelPromise, - commentPromise + commentPromise, + bookmarkPromise, + pinPromise ]) return buildEngagementMapsFromEvents( @@ -537,7 +637,9 @@ export async function fetchPublicationEngagementMaps( dedupeEventsById(highlights), targetAddresses, targetEventIds, - options?.viewerPubkey + options?.viewerPubkey, + dedupeEventsById(bookmarkLists), + dedupeEventsById(pinLists) ) } @@ -546,14 +648,56 @@ function addressHasEngagement( eventId: string | undefined, maps: PublicationEngagementMaps ): { hasLabel: boolean; hasComment: boolean; hasHighlight: boolean } { + const idLower = eventId?.toLowerCase() const hasLabel = - maps.labelAddresses.has(address) || - (eventId ? maps.labelEventIds.has(eventId.toLowerCase()) : false) - const hasComment = maps.commentAddresses.has(address) - const hasHighlight = maps.highlightAddresses.has(address) + maps.labelAddresses.has(address) || (idLower ? maps.labelEventIds.has(idLower) : false) + const hasComment = + maps.commentAddresses.has(address) || (idLower ? maps.commentEventIds.has(idLower) : false) + const hasHighlight = + maps.highlightAddresses.has(address) || (idLower ? maps.highlightEventIds.has(idLower) : false) return { hasLabel, hasComment, hasHighlight } } +function collectBookmarkPinFlagsForTarget( + address: string, + eventId: string | undefined, + maps: PublicationEngagementMaps +): { hasBookmark: boolean; hasPin: boolean } { + const idLower = eventId?.toLowerCase() + return { + hasBookmark: + maps.bookmarkAddresses.has(address) || (idLower ? maps.bookmarkEventIds.has(idLower) : false), + hasPin: maps.pinAddresses.has(address) || (idLower ? maps.pinEventIds.has(idLower) : false) + } +} + +function targetHasPublicationEngagement( + flags: { hasLabel: boolean; hasComment: boolean; hasHighlight: boolean }, + booklistFlags: { hasBooklistLabel: boolean }, + bookmarkPinFlags: { hasBookmark: boolean; hasPin: boolean } +): boolean { + return ( + flags.hasLabel || + flags.hasComment || + flags.hasHighlight || + booklistFlags.hasBooklistLabel || + bookmarkPinFlags.hasBookmark || + bookmarkPinFlags.hasPin + ) +} + +/** True when a library row has any engagement signal from any author. */ +export function publicationEntryHasEngagement(entry: LibraryPublicationEntry): boolean { + return ( + entry.hasLabel || + entry.hasBooklistLabel || + entry.hasComment || + entry.hasHighlight || + entry.hasBookmark || + entry.hasPin + ) +} + function collectLabelNamesForTarget( address: string, eventId: string | undefined, @@ -617,6 +761,8 @@ export function buildLibraryPublicationEntry( let hasLabel = false let hasComment = false let hasHighlight = false + let hasBookmark = false + let hasPin = false let hasBooklistLabel = false let hasMyBooklistLabel = false let hasMyComment = false @@ -628,6 +774,7 @@ export function buildLibraryPublicationEntry( const indexed = indexByAddress.get(addr) const flags = addressHasEngagement(addr, indexed?.id, engagement) const booklistFlags = collectBooklistFlagsForTarget(addr, indexed?.id, engagement) + const bookmarkPinFlags = collectBookmarkPinFlagsForTarget(addr, indexed?.id, engagement) const myFlags = collectMyEngagementFlagsForTarget(addr, indexed?.id, engagement) if (flags.hasLabel) { hasLabel = true @@ -639,15 +786,20 @@ export function buildLibraryPublicationEntry( if (myFlags.hasMyHighlight) hasMyHighlight = true if (flags.hasComment) hasComment = true if (flags.hasHighlight) hasHighlight = true - if (flags.hasLabel || flags.hasComment || flags.hasHighlight) engagementCount++ + if (bookmarkPinFlags.hasBookmark) hasBookmark = true + if (bookmarkPinFlags.hasPin) hasPin = true + if (targetHasPublicationEngagement(flags, booklistFlags, bookmarkPinFlags)) engagementCount++ } const rootFlags = addressHasEngagement(rootAddr ?? '', root.id, engagement) const rootBooklistFlags = collectBooklistFlagsForTarget(rootAddr ?? '', root.id, engagement) + const rootBookmarkPinFlags = collectBookmarkPinFlagsForTarget(rootAddr ?? '', root.id, engagement) const rootMyFlags = collectMyEngagementFlagsForTarget(rootAddr ?? '', root.id, engagement) hasLabel = hasLabel || rootFlags.hasLabel hasComment = hasComment || rootFlags.hasComment hasHighlight = hasHighlight || rootFlags.hasHighlight + hasBookmark = hasBookmark || rootBookmarkPinFlags.hasBookmark + hasPin = hasPin || rootBookmarkPinFlags.hasPin hasBooklistLabel = hasBooklistLabel || rootBooklistFlags.hasBooklistLabel hasMyBooklistLabel = hasMyBooklistLabel || rootBooklistFlags.hasMyBooklistLabel hasMyComment = hasMyComment || rootMyFlags.hasMyComment @@ -666,6 +818,8 @@ export function buildLibraryPublicationEntry( hasMyHighlight, hasComment, hasHighlight, + hasBookmark, + hasPin, engagementCount } } @@ -687,7 +841,7 @@ export function filterEngagedPublications( ): LibraryPublicationEntry[] { return getTopLevelIndexEvents(roots) .map((root) => buildLibraryPublicationEntry(root, indexByAddress, engagement)) - .filter((entry) => entry.hasLabel || entry.hasComment || entry.hasHighlight) + .filter((entry) => publicationEntryHasEngagement(entry)) } export function buildRecentPublicationEntries( @@ -713,7 +867,7 @@ export function computeLibraryFeedRootOrder( const restRoots: Event[] = [] for (const root of topLevel) { const entry = buildLibraryPublicationEntry(root, indexByAddress, engagement) - if (entry.hasLabel || entry.hasComment || entry.hasHighlight) { + if (publicationEntryHasEngagement(entry)) { engagedRoots.push(root) } else { restRoots.push(root) @@ -787,7 +941,9 @@ export function pickLibraryPublicationEntries( export function sortLibraryPublications(entries: LibraryPublicationEntry[]): LibraryPublicationEntry[] { return [...entries].sort((a, b) => { - if (a.hasLabel !== b.hasLabel) return a.hasLabel ? -1 : 1 + const aEngaged = publicationEntryHasEngagement(a) + const bEngaged = publicationEntryHasEngagement(b) + if (aEngaged !== bEngaged) return aEngaged ? -1 : 1 if (a.engagementCount !== b.engagementCount) return b.engagementCount - a.engagementCount return b.event.created_at - a.event.created_at }) @@ -810,7 +966,13 @@ function emptyPublicationEngagementMaps(): PublicationEngagementMaps { myHighlightAddresses: new Set(), myHighlightEventIds: new Set(), commentAddresses: new Set(), - highlightAddresses: new Set() + commentEventIds: new Set(), + highlightAddresses: new Set(), + highlightEventIds: new Set(), + bookmarkAddresses: new Set(), + bookmarkEventIds: new Set(), + pinAddresses: new Set(), + pinEventIds: new Set() } }