From abb4c3734da03c5c807a520438eb01a192c2715e Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 7 Jun 2026 17:33:51 +0200 Subject: [PATCH] format publications and grab gutenberg cover image --- .../Library/LibraryPublicationGrid.tsx | 25 ++++-- src/components/Note/PublicationCard.tsx | 71 +++++++---------- .../Note/PublicationCoverFallback.tsx | 33 ++++++++ src/components/Note/PublicationCoverImage.tsx | 47 ++++++++++++ .../Note/PublicationIndexMetadata.tsx | 19 ++--- .../ReplyNoteList/ThreadQuoteBacklink.tsx | 12 +-- src/hooks/useLibraryPublications.ts | 2 + .../event-metadata.publication-index.test.ts | 27 +++++++ src/lib/event-metadata.ts | 7 ++ src/lib/gutenberg-cover.test.ts | 29 +++++++ src/lib/gutenberg-cover.ts | 28 +++++++ src/lib/library-publication-index.test.ts | 26 +++++++ src/lib/library-publication-index.ts | 76 ++++++++++++++++++- src/lib/nip32-label.test.ts | 34 +++++++++ src/lib/nip32-label.ts | 33 ++++++++ src/lib/parent-reply-blurb.ts | 6 ++ 16 files changed, 404 insertions(+), 71 deletions(-) create mode 100644 src/components/Note/PublicationCoverFallback.tsx create mode 100644 src/components/Note/PublicationCoverImage.tsx create mode 100644 src/lib/gutenberg-cover.test.ts create mode 100644 src/lib/gutenberg-cover.ts create mode 100644 src/lib/nip32-label.test.ts create mode 100644 src/lib/nip32-label.ts diff --git a/src/components/Library/LibraryPublicationGrid.tsx b/src/components/Library/LibraryPublicationGrid.tsx index 161c593b..e17bcfbb 100644 --- a/src/components/Library/LibraryPublicationGrid.tsx +++ b/src/components/Library/LibraryPublicationGrid.tsx @@ -11,12 +11,23 @@ function EngagementBadges({ entry }: { entry: LibraryPublicationEntry }) { return (
- {entry.hasLabel && ( - - - {t('Library badge label')} - - )} + {entry.hasLabel && + (entry.labelNames.length > 0 ? ( + entry.labelNames.map((name) => ( + + + {name} + + )) + ) : ( + + + {t('Library badge label')} + + ))} {entry.hasComment && ( @@ -71,7 +82,7 @@ export default function LibraryPublicationGrid({ 'flex min-w-0 flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden' )} > - +
))} diff --git a/src/components/Note/PublicationCard.tsx b/src/components/Note/PublicationCard.tsx index 869629e1..92275521 100644 --- a/src/components/Note/PublicationCard.tsx +++ b/src/components/Note/PublicationCard.tsx @@ -11,40 +11,31 @@ import { cn } from '@/lib/utils' import { useSecondaryPageOptional, useSmartNoteNavigationOptional } from '@/PageManager' import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' -import { BookOpen } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { useMemo } from 'react' import Image from '../Image' import ArticleCardCoverImage from './ArticleCardCoverImage' +import PublicationCoverFallback from './PublicationCoverFallback' +import PublicationCoverImage from './PublicationCoverImage' import PublicationIndexMetadata from './PublicationIndexMetadata' -function PublicationCoverFallback({ layout }: { layout: 'stacked' | 'row' }) { - return ( -
- -
- ) -} - export default function PublicationCard({ event, className, - disableNavigation = false + disableNavigation = false, + /** Library grid: stacked cover on top, compact cover height. */ + presentation = 'default' }: { event: Event className?: string /** When true (e.g. full note view), card is display-only; no navigate-to-note on click. */ disableNavigation?: boolean + presentation?: 'default' | 'library' }) { const screenSize = useScreenSizeOptional() const isSmallScreen = screenSize?.isSmallScreen ?? false + const useStackedLayout = presentation === 'library' || isSmallScreen + const coverSize = presentation === 'library' ? 'library' : 'default' const { navigateToNote } = useSmartNoteNavigationOptional() const secondaryPage = useSecondaryPageOptional() const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) @@ -57,10 +48,11 @@ export default function PublicationCard({ const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content]) const summaryText = (metadata.summary?.trim() || bodyBlurb).trim() const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) + // Kind 30040 is always a publication index (NKBIP-01). Do not treat `T`/`v` tags as bookstr — + // they mean topic/version there, not NKBIP-08 bible references. const isBookstrEvent = - (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && - !!bookMetadata.book - const isPublicationIndex = event.kind === ExtendedKind.PUBLICATION && !isBookstrEvent + event.kind === ExtendedKind.PUBLICATION_CONTENT && !!bookMetadata.book + const isPublicationIndex = event.kind === ExtendedKind.PUBLICATION const handleCardClick = (e: React.MouseEvent) => { e.stopPropagation() @@ -115,32 +107,27 @@ export default function PublicationCard({ ) : null const cardShellClass = cn( - 'min-w-0 rounded-lg border p-4 transition-colors', + 'min-w-0 rounded-lg border transition-colors', + presentation === 'library' ? 'border-0 p-3' : 'border p-4', disableNavigation ? '' : 'cursor-pointer hover:bg-muted/50' ) if (isPublicationIndex && indexMetadata) { const coverImage = indexMetadata.image?.trim() - const cover = - coverImage ? ( - - ) : ( - - ) + const coverLayout = useStackedLayout ? 'stacked' : 'row' + const cover = coverImage ? ( + + ) : ( + + ) - if (isSmallScreen) { + if (useStackedLayout) { return (
@@ -157,9 +144,9 @@ export default function PublicationCard({ className={cn(cardShellClass, 'overflow-hidden')} onClick={disableNavigation ? undefined : handleCardClick} > -
+
{cover} - +
diff --git a/src/components/Note/PublicationCoverFallback.tsx b/src/components/Note/PublicationCoverFallback.tsx new file mode 100644 index 00000000..a2abc8b7 --- /dev/null +++ b/src/components/Note/PublicationCoverFallback.tsx @@ -0,0 +1,33 @@ +import { cn } from '@/lib/utils' +import { BookOpen } from 'lucide-react' +import { + LIBRARY_PUBLICATION_COVER_MAX_CLASS, + PUBLICATION_COVER_MAX_CLASS +} from './PublicationCoverImage' + +export default function PublicationCoverFallback({ + layout, + size = 'default', + className +}: { + layout: 'stacked' | 'row' + size?: 'library' | 'default' + className?: string +}) { + const maxClass = size === 'library' ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS + + return ( +
+ +
+ ) +} diff --git a/src/components/Note/PublicationCoverImage.tsx b/src/components/Note/PublicationCoverImage.tsx new file mode 100644 index 00000000..b3e23dd0 --- /dev/null +++ b/src/components/Note/PublicationCoverImage.tsx @@ -0,0 +1,47 @@ +import { cn } from '@/lib/utils' +import Image from '../Image' + +/** Max cover height in the library grid (3-column cards). */ +export const LIBRARY_PUBLICATION_COVER_MAX_CLASS = 'max-h-48' + +/** Max cover height in publication detail / default cards. */ +export const PUBLICATION_COVER_MAX_CLASS = 'max-h-[400px]' + +export default function PublicationCoverImage({ + imageUrl, + pubkey, + autoLoadMedia, + size = 'default', + layout = 'stacked', + className +}: { + imageUrl: string + pubkey: string + autoLoadMedia: boolean + size?: 'library' | 'default' + layout?: 'stacked' | 'row' + className?: string +}) { + const maxClass = size === 'library' ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS + + return ( +
+ +
+ ) +} diff --git a/src/components/Note/PublicationIndexMetadata.tsx b/src/components/Note/PublicationIndexMetadata.tsx index b957bfb9..ef4912c7 100644 --- a/src/components/Note/PublicationIndexMetadata.tsx +++ b/src/components/Note/PublicationIndexMetadata.tsx @@ -11,7 +11,8 @@ import { BookOpen, ExternalLink } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import Image from '../Image' +import PublicationCoverFallback from './PublicationCoverFallback' +import PublicationCoverImage from './PublicationCoverImage' function formatAuthorLine(authors: PublicationAuthor[]): string { if (authors.length === 0) return '' @@ -120,16 +121,16 @@ export default function PublicationIndexMetadata({ return (
{isFull && metadata.image?.trim() ? ( - ) : isFull ? ( -
- -
+ ) : null} {showTitle ? ( diff --git a/src/components/ReplyNoteList/ThreadQuoteBacklink.tsx b/src/components/ReplyNoteList/ThreadQuoteBacklink.tsx index 839d02cf..4fec404e 100644 --- a/src/components/ReplyNoteList/ThreadQuoteBacklink.tsx +++ b/src/components/ReplyNoteList/ThreadQuoteBacklink.tsx @@ -3,6 +3,7 @@ import { ExtendedKind } from '@/constants' import { getKindDescription } from '@/lib/kind-description' import { toNote } from '@/lib/link' import { stripNostrIdsFromPlainTextSnippet } from '@/lib/snippet-sanitize' +import { formatNip32LabelSnippet } from '@/lib/nip32-label' import { cn } from '@/lib/utils' import { FormattedTimestamp } from '@/components/FormattedTimestamp' import UserAvatar from '@/components/UserAvatar' @@ -41,15 +42,8 @@ function quoteBacklinkSnippet(event: Event, maxLen = 96): string { } } if (event.kind === kinds.Label) { - const L = event.tags.find((t) => t[0] === 'l' || t[0] === 'L') - if (L) { - const parts = [L[1], L[2], L[3]].filter(Boolean) - if (parts.length) return trim(parts.join(' · ')) - } - if (event.content.trim()) { - const out = trim(event.content) - if (out) return out - } + const snippet = formatNip32LabelSnippet(event, maxLen) + if (snippet) return trim(snippet) } if (event.kind === kinds.Report || event.kind === ExtendedKind.REPORT) { const rep = event.tags.find((t) => t[0] === 'report' || t[0] === 'Report')?.[1] diff --git a/src/hooks/useLibraryPublications.ts b/src/hooks/useLibraryPublications.ts index da5d5271..bbc675b6 100644 --- a/src/hooks/useLibraryPublications.ts +++ b/src/hooks/useLibraryPublications.ts @@ -21,6 +21,8 @@ const LOAD_TIMEOUT_MS = 120_000 const EMPTY_ENGAGEMENT: PublicationEngagementMaps = { labelAddresses: new Set(), labelEventIds: new Set(), + labelValuesByAddress: new Map(), + labelValuesByEventId: new Map(), commentAddresses: new Set(), highlightAddresses: new Set() } diff --git a/src/lib/event-metadata.publication-index.test.ts b/src/lib/event-metadata.publication-index.test.ts index a7c68b9d..0cf0de4f 100644 --- a/src/lib/event-metadata.publication-index.test.ts +++ b/src/lib/event-metadata.publication-index.test.ts @@ -42,6 +42,7 @@ describe('getPublicationIndexMetadataFromEvent', () => { { name: 'Arthur W. Ryder', role: 'translator' } ]) expect(meta.source).toBe('https://www.gutenberg.org/ebooks/21020') + expect(meta.image).toBe('https://www.gutenberg.org/cache/epub/21020/pg21020.cover.medium.jpg') expect(meta.language).toBe('en') expect(meta.releaseDate).toBe('April 10, 2007') expect(meta.type).toBe('book') @@ -58,4 +59,30 @@ describe('getPublicationIndexMetadataFromEvent', () => { expect(meta.title).toBe('Village Life In China') expect(meta.sectionCount).toBe(1) }) + + it('uses Project Gutenberg cover when source is gutenberg and image tag is missing', () => { + const event = indexEvent([ + ['d', 'pg58363-sketches-of-indian-character'], + ['title', 'Sketches of Indian Character'], + ['author', 'James Napier Bailey', 'author'], + ['source', 'https://www.gutenberg.org/ebooks/58363'], + ['a', `30041:${PK}:intro`] + ]) + const meta = getPublicationIndexMetadataFromEvent(event) + expect(meta.image).toBe( + 'https://www.gutenberg.org/cache/epub/58363/pg58363.cover.medium.jpg' + ) + }) + + it('keeps explicit image tag over Gutenberg fallback', () => { + const event = indexEvent([ + ['d', 'book'], + ['title', 'Book'], + ['source', 'https://www.gutenberg.org/ebooks/58363'], + ['image', 'https://example.com/cover.jpg'], + ['a', `30041:${PK}:intro`] + ]) + const meta = getPublicationIndexMetadataFromEvent(event) + expect(meta.image).toBe('https://example.com/cover.jpg') + }) }) diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index b2646960..8cb67790 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -2,6 +2,7 @@ import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, POLL_TYPE } import { TEmoji, TMailboxRelay, TPollType, TRelayList, TRelaySet, TPaymentInfo, TProfile } from '@/types' import { Event, kinds } from 'nostr-tools' import { buildATag } from './draft-event' +import { resolveGutenbergCoverImageUrl } from './gutenberg-cover' import { getLatestEvent, getReplaceableEventIdentifier } from './event' import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' import { formatPubkey, pubkeyToNpub } from './pubkey' @@ -718,8 +719,14 @@ export function getPublicationIndexMetadataFromEvent(event: Event): PublicationI } } + let image = base.image?.trim() || undefined + if (!image) { + image = resolveGutenbergCoverImageUrl(source) + } + return { ...base, + image, authors, source, type, diff --git a/src/lib/gutenberg-cover.test.ts b/src/lib/gutenberg-cover.test.ts new file mode 100644 index 00000000..cb9ae631 --- /dev/null +++ b/src/lib/gutenberg-cover.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { + gutenbergCoverImageUrl, + parseGutenbergEbookId, + resolveGutenbergCoverImageUrl +} from '@/lib/gutenberg-cover' + +describe('gutenberg-cover', () => { + it('parses ebook id from gutenberg.org URLs', () => { + expect(parseGutenbergEbookId('https://www.gutenberg.org/ebooks/58363')).toBe('58363') + expect(parseGutenbergEbookId('https://www.gutenberg.org/ebooks/58363/')).toBe('58363') + expect(parseGutenbergEbookId('https://www.gutenberg.org/files/21020/21020-h/21020-h.htm')).toBe( + '21020' + ) + }) + + it('builds medium cover URL', () => { + expect(gutenbergCoverImageUrl('58363')).toBe( + 'https://www.gutenberg.org/cache/epub/58363/pg58363.cover.medium.jpg' + ) + }) + + it('resolveGutenbergCoverImageUrl requires gutenberg source', () => { + expect( + resolveGutenbergCoverImageUrl('https://www.gutenberg.org/ebooks/58363') + ).toBe('https://www.gutenberg.org/cache/epub/58363/pg58363.cover.medium.jpg') + expect(resolveGutenbergCoverImageUrl('https://example.com/book')).toBeUndefined() + }) +}) diff --git a/src/lib/gutenberg-cover.ts b/src/lib/gutenberg-cover.ts new file mode 100644 index 00000000..48dd3b8f --- /dev/null +++ b/src/lib/gutenberg-cover.ts @@ -0,0 +1,28 @@ +/** Project Gutenberg cover art: https://www.gutenberg.org/cache/epub/{id}/pg{id}.cover.medium.jpg */ + +const GUTENBERG_EBOOK_URL = /gutenberg\.org\/ebooks\/(\d+)/i +const GUTENBERG_FILES_URL = /gutenberg\.org\/files\/(\d+)/i + +export function parseGutenbergEbookId(source: string): string | null { + const trimmed = source.trim() + if (!trimmed) return null + for (const pattern of [GUTENBERG_EBOOK_URL, GUTENBERG_FILES_URL]) { + const match = trimmed.match(pattern) + if (match?.[1]) return match[1] + } + return null +} + +export function gutenbergCoverImageUrl(ebookId: string): string { + const id = ebookId.trim() + return `https://www.gutenberg.org/cache/epub/${id}/pg${id}.cover.medium.jpg` +} + +/** When `source` points at Project Gutenberg, return the standard medium cover URL. */ +export function resolveGutenbergCoverImageUrl(source: string | undefined): string | undefined { + if (!source?.trim()) return undefined + if (!source.toLowerCase().includes('gutenberg')) return undefined + const id = parseGutenbergEbookId(source) + if (!id) return undefined + return gutenbergCoverImageUrl(id) +} diff --git a/src/lib/library-publication-index.test.ts b/src/lib/library-publication-index.test.ts index 352ca7de..6f130fdc 100644 --- a/src/lib/library-publication-index.test.ts +++ b/src/lib/library-publication-index.test.ts @@ -74,6 +74,31 @@ describe('library-publication-index', () => { const engaged = filterEngagedPublications([root], indexByAddress, engagement) expect(engaged).toHaveLength(1) expect(engaged[0].hasLabel).toBe(true) + expect(engaged[0].labelNames).toEqual(['MIT']) + }) + + it('extracts NIP-32 l tag values, not L namespace declarations', () => { + const rootAddr = `30040:${PK}:jane-eyre-an-autobiography` + const root = indexEvent('jane-eyre-an-autobiography', [`30041:${PK}:intro`]) + root.tags = [['d', 'jane-eyre-an-autobiography'], ['title', 'Jane Eyre'], ['a', `30041:${PK}:intro`]] + const indexByAddress = buildIndexByAddress([root]) + const label: Event = { + id: '5'.repeat(64), + kind: ExtendedKind.LABEL, + pubkey: 'f'.repeat(64), + created_at: 50, + content: '', + tags: [ + ['L', 'ugc'], + ['l', 'booklist', 'ugc'], + ['a', rootAddr, 'wss://theforest.nostr1.com'] + ], + sig: 'e'.repeat(128) + } + const engagement = buildEngagementMapsFromEvents([label], [], []) + const engaged = filterEngagedPublications([root], indexByAddress, engagement) + expect(engaged).toHaveLength(1) + expect(engaged[0].labelNames).toEqual(['booklist']) }) it('filterLibraryPublicationsBySearch matches title', () => { @@ -82,6 +107,7 @@ describe('library-publication-index', () => { { event: root, hasLabel: true, + labelNames: ['MIT'], hasComment: false, hasHighlight: false, engagementCount: 1 diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts index a222ab5f..8f3710da 100644 --- a/src/lib/library-publication-index.ts +++ b/src/lib/library-publication-index.ts @@ -7,6 +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 { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationSearch } from '@/lib/index-relay-http' import { buildIndexByAddress, @@ -57,6 +58,8 @@ const QUERY_OPTS = { export type PublicationEngagementMaps = { labelAddresses: Set labelEventIds: Set + labelValuesByAddress: Map> + labelValuesByEventId: Map> commentAddresses: Set highlightAddresses: Set } @@ -64,6 +67,8 @@ export type PublicationEngagementMaps = { export type LibraryPublicationEntry = { event: Event hasLabel: boolean + /** NIP-32 `l` tag values from kind-1985 events (e.g. "booklist"), not `L` namespaces (e.g. "ugc"). */ + labelNames: string[] hasComment: boolean hasHighlight: boolean engagementCount: number @@ -301,16 +306,36 @@ export function buildEngagementMapsFromEvents( ): PublicationEngagementMaps { const labelAddresses = new Set() const labelEventIds = new Set() + const labelValuesByAddress = new Map>() + const labelValuesByEventId = new Map>() 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 addLabelValues = (map: Map>, key: string, values: string[]) => { + if (values.length === 0) return + let set = map.get(key) + if (!set) { + set = new Set() + map.set(key, set) + } + for (const value of values) set.add(value) + } + for (const ev of labels) { + const labelValues = extractNip32LabelValues(ev.tags) for (const tag of ev.tags) { - if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) labelAddresses.add(tag[1]) - if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) labelEventIds.add(tag[1].toLowerCase()) + if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) { + labelAddresses.add(tag[1]) + addLabelValues(labelValuesByAddress, tag[1], labelValues) + } + if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) { + const eventId = tag[1].toLowerCase() + labelEventIds.add(eventId) + addLabelValues(labelValuesByEventId, eventId, labelValues) + } } } @@ -326,7 +351,14 @@ export function buildEngagementMapsFromEvents( } } - return { labelAddresses, labelEventIds, commentAddresses, highlightAddresses } + return { + labelAddresses, + labelEventIds, + labelValuesByAddress, + labelValuesByEventId, + commentAddresses, + highlightAddresses + } } async function fetchHttpEngagementByAddresses( @@ -367,6 +399,8 @@ export async function fetchPublicationEngagementMaps( return { labelAddresses: new Set(), labelEventIds: new Set(), + labelValuesByAddress: new Map(), + labelValuesByEventId: new Map(), commentAddresses: new Set(), highlightAddresses: new Set() } @@ -442,6 +476,24 @@ function addressHasEngagement( return { hasLabel, hasComment, hasHighlight } } +function collectLabelNamesForTarget( + address: string, + eventId: string | undefined, + maps: PublicationEngagementMaps, + out: Set +): void { + const byAddress = maps.labelValuesByAddress.get(address) + if (byAddress) { + for (const value of byAddress) out.add(value) + } + if (eventId) { + const byEventId = maps.labelValuesByEventId.get(eventId.toLowerCase()) + if (byEventId) { + for (const value of byEventId) out.add(value) + } + } +} + export function filterEngagedPublications( roots: Event[], indexByAddress: Map, @@ -458,11 +510,15 @@ export function filterEngagedPublications( 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 + 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++ @@ -472,11 +528,15 @@ export function filterEngagedPublications( hasLabel = hasLabel || rootFlags.hasLabel hasComment = hasComment || rootFlags.hasComment hasHighlight = hasHighlight || rootFlags.hasHighlight + 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) @@ -497,6 +557,7 @@ export function buildRecentPublicationEntries( .map((event) => ({ event, hasLabel: false, + labelNames: [], hasComment: false, hasHighlight: false, engagementCount: 0 @@ -525,6 +586,8 @@ 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() } @@ -596,6 +659,7 @@ function libraryEntriesFromRoots( return { event: root, hasLabel: false, + labelNames: [], hasComment: false, hasHighlight: false, engagementCount: 0 @@ -1102,6 +1166,8 @@ export async function loadLibraryPublicationIndex( resolve({ labelAddresses: new Set(), labelEventIds: new Set(), + labelValuesByAddress: new Map(), + labelValuesByEventId: new Map(), commentAddresses: new Set(), highlightAddresses: new Set() }), @@ -1118,6 +1184,8 @@ export async function loadLibraryPublicationIndex( engagement = { labelAddresses: new Set(), labelEventIds: new Set(), + labelValuesByAddress: new Map(), + labelValuesByEventId: new Map(), commentAddresses: new Set(), highlightAddresses: new Set() } diff --git a/src/lib/nip32-label.test.ts b/src/lib/nip32-label.test.ts new file mode 100644 index 00000000..d3c9f301 --- /dev/null +++ b/src/lib/nip32-label.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { extractNip32LabelValues, formatNip32LabelSnippet } from '@/lib/nip32-label' +import type { Event } from 'nostr-tools' + +describe('nip32-label', () => { + it('extracts lowercase l tag values, not uppercase L namespace declarations', () => { + const tags = [ + ['L', 'ugc'], + ['l', 'booklist', 'ugc'], + ['a', '30040:abc:book', 'wss://relay.example'] + ] + expect(extractNip32LabelValues(tags)).toEqual(['booklist']) + }) + + it('dedupes label values case-insensitively', () => { + const tags = [ + ['l', 'Booklist', 'ugc'], + ['l', 'booklist', 'ugc'] + ] + expect(extractNip32LabelValues(tags)).toEqual(['Booklist']) + }) + + it('formatNip32LabelSnippet prefers l tag values over content', () => { + const event = { + kind: 1985, + content: 'ignored', + tags: [ + ['L', 'license'], + ['l', 'MIT', 'license'] + ] + } as Event + expect(formatNip32LabelSnippet(event)).toBe('MIT') + }) +}) diff --git a/src/lib/nip32-label.ts b/src/lib/nip32-label.ts new file mode 100644 index 00000000..55a4796b --- /dev/null +++ b/src/lib/nip32-label.ts @@ -0,0 +1,33 @@ +import type { Event } from 'nostr-tools' + +/** NIP-32 lowercase `l` tag values (actual labels), not uppercase `L` namespace declarations. */ +export function extractNip32LabelValues(tags: string[][]): string[] { + const out: string[] = [] + const seen = new Set() + for (const tag of tags) { + if (tag[0] !== 'l') continue + const value = tag[1]?.trim() + if (!value) continue + const key = value.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(value) + } + return out +} + +/** One-line display text for a kind-1985 label event. */ +export function formatNip32LabelSnippet(event: Event, maxLen = 96): string { + const values = extractNip32LabelValues(event.tags) + if (values.length > 0) { + const joined = values.join(' · ') + if (joined.length <= maxLen) return joined + return `${joined.slice(0, maxLen - 1).trimEnd()}…` + } + const content = event.content?.trim() + if (content) { + if (content.length <= maxLen) return content + return `${content.slice(0, maxLen - 1).trimEnd()}…` + } + return '' +} diff --git a/src/lib/parent-reply-blurb.ts b/src/lib/parent-reply-blurb.ts index b5e8ea3e..1dea5b34 100644 --- a/src/lib/parent-reply-blurb.ts +++ b/src/lib/parent-reply-blurb.ts @@ -5,6 +5,7 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { tagNameEquals } from '@/lib/tag' +import { formatNip32LabelSnippet } from '@/lib/nip32-label' import { stripTrailingStringifiedNostrEvent } from '@/lib/nostr-event-json' import { Event, kinds } from 'nostr-tools' @@ -46,6 +47,11 @@ export function getParentReplyBlurbDisplayText( const subjectTag = event.tags.find(tagNameEquals('subject'))?.[1]?.trim() if (subjectTag) return truncateBlurb(stripMarkupForPreview(subjectTag), maxLen) + if (event.kind === kinds.Label) { + const labelSnippet = formatNip32LabelSnippet(event, maxLen) + if (labelSnippet) return labelSnippet + } + if ( event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.PUBLICATION ||