Browse Source

render longform in feeds and embedding

imwald
Silberengel 1 month ago
parent
commit
fab83b09d8
  1. 22
      src/components/ContentPreview/LongFormArticlePreview.tsx
  2. 4
      src/components/ContentPreview/index.tsx
  3. 136
      src/components/Note/LongFormCard.tsx
  4. 10
      src/components/Note/PublicationCard.tsx
  5. 10
      src/components/Note/WikiCard.tsx
  6. 7
      src/components/Note/index.tsx
  7. 26
      src/lib/card-event-body-blurb.ts
  8. 17
      src/lib/event-metadata.ts

22
src/components/ContentPreview/LongFormArticlePreview.tsx

@ -1,22 +0,0 @@
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function LongFormArticlePreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Article')}] <span className="italic pr-0.5">{metadata.title}</span>
</div>
)
}

4
src/components/ContentPreview/index.tsx

@ -22,7 +22,7 @@ import CommunityDefinitionPreview from './CommunityDefinitionPreview'
import GroupMetadataPreview from './GroupMetadataPreview' import GroupMetadataPreview from './GroupMetadataPreview'
import HighlightPreview from './HighlightPreview' import HighlightPreview from './HighlightPreview'
import LiveEventPreview from './LiveEventPreview' import LiveEventPreview from './LiveEventPreview'
import LongFormArticlePreview from './LongFormArticlePreview' import LongFormCard from '../Note/LongFormCard'
import NormalContentPreview from './NormalContentPreview' import NormalContentPreview from './NormalContentPreview'
import PictureNotePreview from './PictureNotePreview' import PictureNotePreview from './PictureNotePreview'
import PollPreview from './PollPreview' import PollPreview from './PollPreview'
@ -181,7 +181,7 @@ export default function ContentPreview({
} }
if (event.kind === kinds.LongFormArticle) { if (event.kind === kinds.LongFormArticle) {
return withKindRow(<LongFormArticlePreview event={previewEvent} />) return withKindRow(<LongFormCard event={previewEvent} interactive={false} />)
} }
if (isNip71StyleVideoKind(event.kind)) { if (isNip71StyleVideoKind(event.kind)) {

136
src/components/Note/LongFormCard.tsx

@ -0,0 +1,136 @@
import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link'
import { useSecondaryPageOptional } from '@/PageManager'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { cn } from '@/lib/utils'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Image from '../Image'
/**
* Feed / embed / preview surface for NIP-23 long-form (kind 30023): title, summary, image, tags no Show more body.
* Full article stays on the note page ({@link showFull} on {@link Note}).
*/
export default function LongFormCard({
event,
className,
/** When false (e.g. parent-reply preview strip), card is non-interactive like the old one-line preview. */
interactive = true
}: {
event: Event
className?: string
interactive?: boolean
}) {
const { t } = useTranslation()
const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false
const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => {
window.location.href = url
})
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content])
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim()
const displayTitle = metadata.title?.trim() || t('Long-form Article')
const handleCardClick = (e: React.MouseEvent) => {
if (!interactive) return
e.stopPropagation()
push(toNote(event.id))
}
const titleComponent = (
<div className="min-w-0 text-xl font-semibold break-words sm:line-clamp-2">{displayTitle}</div>
)
const tagsComponent = interactive && metadata.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{metadata.tags.map((tag) => (
<div
key={tag}
className="flex max-w-32 cursor-pointer items-center rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
)
const tagsReadonly = !interactive && metadata.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{metadata.tags.map((tag) => (
<span
key={tag}
className="flex max-w-32 items-center rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground"
>
#<span className="truncate">{tag}</span>
</span>
))}
</div>
)
const summaryComponent = summaryText ? (
<div className="text-base text-muted-foreground line-clamp-4 break-words">{summaryText}</div>
) : null
const shellClass = cn(className, !interactive && 'pointer-events-none')
const cardClass = cn(
'rounded-lg border p-4 transition-colors',
interactive && 'cursor-pointer hover:bg-muted/50'
)
if (isSmallScreen) {
return (
<div className={shellClass}>
<div className={cardClass} onClick={interactive ? handleCardClick : undefined}>
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="mb-3 aspect-video w-full max-w-[400px]"
hideIfError
/>
)}
<div className="space-y-2">
{titleComponent}
{summaryComponent}
{tagsComponent}
{tagsReadonly}
</div>
</div>
</div>
)
}
return (
<div className={cn('w-full min-w-0', shellClass)}>
<div className={cn(cardClass, 'min-w-0')} onClick={interactive ? handleCardClick : undefined}>
<div className="flex min-w-0 gap-4">
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
classNames={{ wrapper: 'w-auto max-w-[400px] shrink-0' }}
className="h-44 max-w-[400px] shrink rounded-lg bg-foreground object-cover aspect-[4/3] xl:aspect-video"
hideIfError
/>
)}
<div className="min-w-0 flex-1 basis-0 space-y-2 overflow-hidden">
{titleComponent}
{summaryComponent}
{tagsComponent}
{tagsReadonly}
</div>
</div>
</div>
</div>
)
}

10
src/components/Note/PublicationCard.tsx

@ -1,3 +1,4 @@
import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -24,6 +25,8 @@ export default function PublicationCard({
const contentPolicy = useContentPolicyOptional() const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content])
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim()
const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book
@ -69,11 +72,11 @@ export default function PublicationCard({
</div> </div>
) )
const summaryComponent = metadata.summary && ( const summaryComponent = summaryText ? (
<div className="min-w-0 max-w-full text-base text-muted-foreground line-clamp-4 break-words"> <div className="min-w-0 max-w-full text-base text-muted-foreground line-clamp-4 break-words">
{metadata.summary} {summaryText}
</div> </div>
) ) : null
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
@ -111,6 +114,7 @@ export default function PublicationCard({
{metadata.image && autoLoadMedia && ( {metadata.image && autoLoadMedia && (
<Image <Image
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
classNames={{ wrapper: 'w-auto max-w-[min(400px,42%)] shrink-0 xl:max-w-[400px]' }}
className="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]" className="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]"
hideIfError hideIfError
/> />

10
src/components/Note/WikiCard.tsx

@ -1,3 +1,4 @@
import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { useSecondaryPageOptional } from '@/PageManager' import { useSecondaryPageOptional } from '@/PageManager'
@ -21,6 +22,8 @@ export default function WikiCard({
const contentPolicy = useContentPolicyOptional() const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content])
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim()
const handleCardClick = (e: React.MouseEvent) => { const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
@ -46,9 +49,9 @@ export default function WikiCard({
</div> </div>
) )
const summaryComponent = metadata.summary && ( const summaryComponent = summaryText ? (
<div className="text-base text-muted-foreground line-clamp-4">{metadata.summary}</div> <div className="text-base text-muted-foreground line-clamp-4 break-words">{summaryText}</div>
) ) : null
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
@ -84,6 +87,7 @@ export default function WikiCard({
{metadata.image && autoLoadMedia && ( {metadata.image && autoLoadMedia && (
<Image <Image
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
classNames={{ wrapper: 'w-auto max-w-[400px] shrink-0' }}
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44 max-w-[400px]" className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44 max-w-[400px]"
hideIfError hideIfError
/> />

7
src/components/Note/index.tsx

@ -60,6 +60,7 @@ import AsciidocArticle from './AsciidocArticle/AsciidocArticle'
import PublicationCard from './PublicationCard' import PublicationCard from './PublicationCard'
import PublicationIndex from './PublicationIndex/PublicationIndex' import PublicationIndex from './PublicationIndex/PublicationIndex'
import WikiCard from './WikiCard' import WikiCard from './WikiCard'
import LongFormCard from './LongFormCard'
import MutedNote from './MutedNote' import MutedNote from './MutedNote'
import NsfwNote from './NsfwNote' import NsfwNote from './NsfwNote'
import PictureNote from './PictureNote' import PictureNote from './PictureNote'
@ -329,7 +330,11 @@ export default function Note({
<PublicationCard className="mt-2" event={displayEvent} /> <PublicationCard className="mt-2" event={displayEvent} />
) )
} else if (event.kind === kinds.LongFormArticle) { } else if (event.kind === kinds.LongFormArticle) {
content = renderEventContent({ hideMetadata: true }) content = showFull ? (
renderEventContent({ hideMetadata: true })
) : (
<LongFormCard className="mt-2" event={displayEvent} />
)
} else if (event.kind === kinds.LiveEvent || event.kind === 30312 || event.kind === 30313) { } else if (event.kind === kinds.LiveEvent || event.kind === 30312 || event.kind === 30313) {
content = <LiveEvent className="mt-2" event={displayEvent} /> content = <LiveEvent className="mt-2" event={displayEvent} />
} else if (event.kind === ExtendedKind.GROUP_METADATA) { } else if (event.kind === ExtendedKind.GROUP_METADATA) {

26
src/lib/card-event-body-blurb.ts

@ -0,0 +1,26 @@
/** Max length for card blurb when the `summary` tag is absent (article-style events). */
export const CARD_EVENT_BODY_BLURB_MAX = 250
/**
* First {@link CARD_EVENT_BODY_BLURB_MAX} characters of event `content`, stripped of common
* markdown / lightweight HTML so feed cards can show a readable teaser without a `summary` tag.
*/
export function cardEventBodyBlurb(raw: string | undefined, max = CARD_EVENT_BODY_BLURB_MAX): string {
if (raw == null) return ''
let s = raw.trim()
if (!s) return ''
s = s.replace(/```[\s\S]*?```/g, ' ')
s = s.replace(/`[^`]+`/g, ' ')
s = s.replace(/<[^>]+>/g, ' ')
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
s = s.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
s = s.replace(/^#{1,6}\s+/gm, '')
s = s.replace(/\*\*|__/g, '')
s = s.replace(/~~/g, '')
s = s.replace(/\*|_/g, ' ')
s = s.replace(/\s+/g, ' ').trim()
if (!s) return ''
if (s.length <= max) return s
return `${s.slice(0, max).trimEnd()}`
}

17
src/lib/event-metadata.ts

@ -518,14 +518,15 @@ export function getLongFormArticleMetadataFromEvent(event: Event) {
const tags = new Set<string>() const tags = new Set<string>()
event.tags.forEach(([tagName, tagValue]) => { event.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'title') { const n = tagName?.toLowerCase()
title = tagValue if (n === 'title' && tagValue?.trim()) {
} else if (tagName === 'summary') { title = tagValue.trim()
summary = tagValue } else if (n === 'summary' && tagValue?.trim()) {
} else if (tagName === 'image') { summary = tagValue.trim()
image = tagValue } else if (n === 'image' && tagValue?.trim()) {
} else if (tagName === 't' && tagValue && tags.size < 6) { image = tagValue.trim()
tags.add(tagValue.toLowerCase()) } else if (n === 't' && tagValue?.trim() && tags.size < 6) {
tags.add(tagValue.trim().replace(/^#/, '').toLowerCase())
} }
}) })

Loading…
Cancel
Save