Browse Source

format publications and grab gutenberg cover image

imwald
Silberengel 1 week ago
parent
commit
abb4c3734d
  1. 17
      src/components/Library/LibraryPublicationGrid.tsx
  2. 65
      src/components/Note/PublicationCard.tsx
  3. 33
      src/components/Note/PublicationCoverFallback.tsx
  4. 47
      src/components/Note/PublicationCoverImage.tsx
  5. 19
      src/components/Note/PublicationIndexMetadata.tsx
  6. 12
      src/components/ReplyNoteList/ThreadQuoteBacklink.tsx
  7. 2
      src/hooks/useLibraryPublications.ts
  8. 27
      src/lib/event-metadata.publication-index.test.ts
  9. 7
      src/lib/event-metadata.ts
  10. 29
      src/lib/gutenberg-cover.test.ts
  11. 28
      src/lib/gutenberg-cover.ts
  12. 26
      src/lib/library-publication-index.test.ts
  13. 76
      src/lib/library-publication-index.ts
  14. 34
      src/lib/nip32-label.test.ts
  15. 33
      src/lib/nip32-label.ts
  16. 6
      src/lib/parent-reply-blurb.ts

17
src/components/Library/LibraryPublicationGrid.tsx

@ -11,12 +11,23 @@ function EngagementBadges({ entry }: { entry: LibraryPublicationEntry }) { @@ -11,12 +11,23 @@ function EngagementBadges({ entry }: { entry: LibraryPublicationEntry }) {
return (
<div className="flex flex-wrap gap-2 px-1 pb-2">
{entry.hasLabel && (
{entry.hasLabel &&
(entry.labelNames.length > 0 ? (
entry.labelNames.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 />
{name}
</span>
))
) : (
<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>
)}
))}
{entry.hasComment && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
<MessageSquare className="size-3" aria-hidden />
@ -71,7 +82,7 @@ export default function LibraryPublicationGrid({ @@ -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'
)}
>
<PublicationCard event={entry.event} className="border-0 shadow-none rounded-none" />
<PublicationCard event={entry.event} presentation="library" className="border-0 shadow-none rounded-none" />
<EngagementBadges entry={entry} />
</div>
))}

65
src/components/Note/PublicationCard.tsx

@ -11,40 +11,31 @@ import { cn } from '@/lib/utils' @@ -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 (
<div
className={cn(
'flex items-center justify-center rounded-lg bg-muted text-muted-foreground',
layout === 'stacked'
? 'mb-3 aspect-video w-full max-w-full'
: 'aspect-[4/3] h-44 max-h-44 w-auto max-w-[min(400px,42%)] min-w-0 shrink rounded-lg xl:aspect-video xl:max-w-[400px]'
)}
>
<BookOpen className={layout === 'stacked' ? 'size-10' : 'size-12'} aria-hidden />
</div>
)
}
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({ @@ -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({ @@ -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 ? (
<Image
image={{ url: coverImage, pubkey: event.pubkey }}
className={
isSmallScreen
? 'mb-3 aspect-video w-full max-w-full'
: 'aspect-[4/3] h-44 max-h-44 w-auto max-w-[min(400px,42%)] min-w-0 shrink rounded-lg bg-foreground object-cover xl:aspect-video xl:max-w-[400px]'
}
classNames={
isSmallScreen ? undefined : { wrapper: 'w-auto max-w-[min(400px,42%)] shrink-0 xl:max-w-[400px]' }
}
hideIfError
holdUntilClick={!autoLoadMedia}
const coverLayout = useStackedLayout ? 'stacked' : 'row'
const cover = coverImage ? (
<PublicationCoverImage
imageUrl={coverImage}
pubkey={event.pubkey}
autoLoadMedia={autoLoadMedia}
size={coverSize}
layout={coverLayout}
/>
) : (
<PublicationCoverFallback layout={isSmallScreen ? 'stacked' : 'row'} />
<PublicationCoverFallback layout={coverLayout} size={coverSize} />
)
if (isSmallScreen) {
if (useStackedLayout) {
return (
<div className={cn('w-full min-w-0', className)}>
<div className={cardShellClass} onClick={disableNavigation ? undefined : handleCardClick}>
@ -157,9 +144,9 @@ export default function PublicationCard({ @@ -157,9 +144,9 @@ export default function PublicationCard({
className={cn(cardShellClass, 'overflow-hidden')}
onClick={disableNavigation ? undefined : handleCardClick}
>
<div className="flex min-w-0 gap-4">
<div className="flex min-w-0 items-start gap-4">
{cover}
<PublicationIndexMetadata event={event} variant="compact" className="min-h-0 min-w-[10rem] flex-1 basis-0" />
<PublicationIndexMetadata event={event} variant="compact" className="min-h-0 min-w-0 flex-1 basis-0" />
</div>
</div>
</div>

33
src/components/Note/PublicationCoverFallback.tsx

@ -0,0 +1,33 @@ @@ -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 (
<div
className={cn(
'flex shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground',
maxClass,
layout === 'stacked' ? 'aspect-[3/4] w-full' : 'aspect-[3/4] w-full max-w-[9rem] sm:max-w-[10rem]',
layout === 'stacked' && size === 'default' && 'mb-3',
layout === 'stacked' && size === 'library' && 'mb-2',
className
)}
>
<BookOpen className={size === 'library' ? 'size-8' : 'size-10'} aria-hidden />
</div>
)
}

47
src/components/Note/PublicationCoverImage.tsx

@ -0,0 +1,47 @@ @@ -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 (
<div
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted',
maxClass,
layout === 'stacked' ? 'w-full' : 'w-full max-w-[9rem] sm:max-w-[10rem]',
layout === 'stacked' && size === 'default' && 'mb-3',
layout === 'stacked' && size === 'library' && 'mb-2',
className
)}
>
<Image
image={{ url: imageUrl, pubkey }}
className={cn(maxClass, 'max-w-full object-contain')}
classNames={{ wrapper: 'inline-block w-auto max-w-full' }}
hideIfError
holdUntilClick={!autoLoadMedia}
/>
</div>
)
}

19
src/components/Note/PublicationIndexMetadata.tsx

@ -11,7 +11,8 @@ import { BookOpen, ExternalLink } from 'lucide-react' @@ -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({ @@ -120,16 +121,16 @@ export default function PublicationIndexMetadata({
return (
<div className={cn('min-w-0 space-y-2', isFull && 'space-y-4', className)}>
{isFull && metadata.image?.trim() ? (
<Image
image={{ url: metadata.image.trim(), pubkey: event.pubkey }}
className="aspect-[16/10] w-full max-w-xl rounded-lg bg-foreground object-cover"
hideIfError
holdUntilClick={!autoLoadMedia}
<PublicationCoverImage
imageUrl={metadata.image.trim()}
pubkey={event.pubkey}
autoLoadMedia={autoLoadMedia}
size="default"
layout="stacked"
className="mb-0 w-fit max-w-xl"
/>
) : isFull ? (
<div className="flex aspect-[16/10] w-full max-w-xl items-center justify-center rounded-lg bg-muted text-muted-foreground">
<BookOpen className="size-14" aria-hidden />
</div>
<PublicationCoverFallback layout="stacked" size="default" className="w-fit max-w-xl" />
) : null}
{showTitle ? (

12
src/components/ReplyNoteList/ThreadQuoteBacklink.tsx

@ -3,6 +3,7 @@ import { ExtendedKind } from '@/constants' @@ -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 { @@ -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]

2
src/hooks/useLibraryPublications.ts

@ -21,6 +21,8 @@ const LOAD_TIMEOUT_MS = 120_000 @@ -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()
}

27
src/lib/event-metadata.publication-index.test.ts

@ -42,6 +42,7 @@ describe('getPublicationIndexMetadataFromEvent', () => { @@ -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', () => { @@ -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')
})
})

7
src/lib/event-metadata.ts

@ -2,6 +2,7 @@ import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, POLL_TYPE } @@ -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 @@ -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,

29
src/lib/gutenberg-cover.test.ts

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

28
src/lib/gutenberg-cover.ts

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

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

@ -74,6 +74,31 @@ describe('library-publication-index', () => { @@ -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', () => { @@ -82,6 +107,7 @@ describe('library-publication-index', () => {
{
event: root,
hasLabel: true,
labelNames: ['MIT'],
hasComment: false,
hasHighlight: false,
engagementCount: 1

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

@ -7,6 +7,7 @@ import { @@ -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 = { @@ -57,6 +58,8 @@ const QUERY_OPTS = {
export type PublicationEngagementMaps = {
labelAddresses: Set<string>
labelEventIds: Set<string>
labelValuesByAddress: Map<string, Set<string>>
labelValuesByEventId: Map<string, Set<string>>
commentAddresses: Set<string>
highlightAddresses: Set<string>
}
@ -64,6 +67,8 @@ export type PublicationEngagementMaps = { @@ -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( @@ -301,16 +306,36 @@ export function buildEngagementMapsFromEvents(
): 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 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 addLabelValues = (map: Map<string, Set<string>>, key: string, values: string[]) => {
if (values.length === 0) return
let set = map.get(key)
if (!set) {
set = new Set<string>()
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( @@ -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( @@ -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( @@ -442,6 +476,24 @@ function addressHasEngagement(
return { hasLabel, hasComment, hasHighlight }
}
function collectLabelNamesForTarget(
address: string,
eventId: string | undefined,
maps: PublicationEngagementMaps,
out: Set<string>
): 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<string, Event>,
@ -458,11 +510,15 @@ export function filterEngagedPublications( @@ -458,11 +510,15 @@ export function filterEngagedPublications(
let hasComment = false
let hasHighlight = 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)
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( @@ -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( @@ -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 @@ -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( @@ -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( @@ -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( @@ -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()
}

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

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

33
src/lib/nip32-label.ts

@ -0,0 +1,33 @@ @@ -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<string>()
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 ''
}

6
src/lib/parent-reply-blurb.ts

@ -5,6 +5,7 @@ import { @@ -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( @@ -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 ||

Loading…
Cancel
Save