From fab83b09d85cd63b48a9331ec7681bf943197fe4 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 5 May 2026 18:17:28 +0200 Subject: [PATCH] render longform in feeds and embedding --- .../ContentPreview/LongFormArticlePreview.tsx | 22 --- src/components/ContentPreview/index.tsx | 4 +- src/components/Note/LongFormCard.tsx | 136 ++++++++++++++++++ src/components/Note/PublicationCard.tsx | 10 +- src/components/Note/WikiCard.tsx | 10 +- src/components/Note/index.tsx | 7 +- src/lib/card-event-body-blurb.ts | 26 ++++ src/lib/event-metadata.ts | 17 +-- 8 files changed, 193 insertions(+), 39 deletions(-) delete mode 100644 src/components/ContentPreview/LongFormArticlePreview.tsx create mode 100644 src/components/Note/LongFormCard.tsx create mode 100644 src/lib/card-event-body-blurb.ts diff --git a/src/components/ContentPreview/LongFormArticlePreview.tsx b/src/components/ContentPreview/LongFormArticlePreview.tsx deleted file mode 100644 index c42cb965..00000000 --- a/src/components/ContentPreview/LongFormArticlePreview.tsx +++ /dev/null @@ -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 ( -
- [{t('Article')}] {metadata.title} -
- ) -} diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index 335e3e7f..05025070 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -22,7 +22,7 @@ import CommunityDefinitionPreview from './CommunityDefinitionPreview' import GroupMetadataPreview from './GroupMetadataPreview' import HighlightPreview from './HighlightPreview' import LiveEventPreview from './LiveEventPreview' -import LongFormArticlePreview from './LongFormArticlePreview' +import LongFormCard from '../Note/LongFormCard' import NormalContentPreview from './NormalContentPreview' import PictureNotePreview from './PictureNotePreview' import PollPreview from './PollPreview' @@ -181,7 +181,7 @@ export default function ContentPreview({ } if (event.kind === kinds.LongFormArticle) { - return withKindRow() + return withKindRow() } if (isNip71StyleVideoKind(event.kind)) { diff --git a/src/components/Note/LongFormCard.tsx b/src/components/Note/LongFormCard.tsx new file mode 100644 index 00000000..67a9c566 --- /dev/null +++ b/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 = ( +
{displayTitle}
+ ) + + const tagsComponent = interactive && metadata.tags.length > 0 && ( +
+ {metadata.tags.map((tag) => ( +
{ + e.stopPropagation() + push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) + }} + > + #{tag} +
+ ))} +
+ ) + + const tagsReadonly = !interactive && metadata.tags.length > 0 && ( +
+ {metadata.tags.map((tag) => ( + + #{tag} + + ))} +
+ ) + + const summaryComponent = summaryText ? ( +
{summaryText}
+ ) : 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 ( +
+
+ {metadata.image && autoLoadMedia && ( + + )} +
+ {titleComponent} + {summaryComponent} + {tagsComponent} + {tagsReadonly} +
+
+
+ ) + } + + return ( +
+
+
+ {metadata.image && autoLoadMedia && ( + + )} +
+ {titleComponent} + {summaryComponent} + {tagsComponent} + {tagsReadonly} +
+
+
+
+ ) +} diff --git a/src/components/Note/PublicationCard.tsx b/src/components/Note/PublicationCard.tsx index 6bfd5331..541d5bd6 100644 --- a/src/components/Note/PublicationCard.tsx +++ b/src/components/Note/PublicationCard.tsx @@ -1,3 +1,4 @@ +import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList } from '@/lib/link' import { cn } from '@/lib/utils' @@ -24,6 +25,8 @@ export default function PublicationCard({ 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 bookMetadata = useMemo(() => extractBookMetadata(event), [event]) const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book @@ -69,11 +72,11 @@ export default function PublicationCard({ ) - const summaryComponent = metadata.summary && ( + const summaryComponent = summaryText ? (
- {metadata.summary} + {summaryText}
- ) + ) : null if (isSmallScreen) { return ( @@ -111,6 +114,7 @@ export default function PublicationCard({ {metadata.image && autoLoadMedia && ( diff --git a/src/components/Note/WikiCard.tsx b/src/components/Note/WikiCard.tsx index 1ac5f999..42eb2fad 100644 --- a/src/components/Note/WikiCard.tsx +++ b/src/components/Note/WikiCard.tsx @@ -1,3 +1,4 @@ +import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList } from '@/lib/link' import { useSecondaryPageOptional } from '@/PageManager' @@ -21,6 +22,8 @@ export default function WikiCard({ 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 handleCardClick = (e: React.MouseEvent) => { e.stopPropagation() @@ -46,9 +49,9 @@ export default function WikiCard({ ) - const summaryComponent = metadata.summary && ( -
{metadata.summary}
- ) + const summaryComponent = summaryText ? ( +
{summaryText}
+ ) : null if (isSmallScreen) { return ( @@ -84,6 +87,7 @@ export default function WikiCard({ {metadata.image && autoLoadMedia && ( diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 00aba1d1..bd143c4a 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -60,6 +60,7 @@ import AsciidocArticle from './AsciidocArticle/AsciidocArticle' import PublicationCard from './PublicationCard' import PublicationIndex from './PublicationIndex/PublicationIndex' import WikiCard from './WikiCard' +import LongFormCard from './LongFormCard' import MutedNote from './MutedNote' import NsfwNote from './NsfwNote' import PictureNote from './PictureNote' @@ -329,7 +330,11 @@ export default function Note({ ) } else if (event.kind === kinds.LongFormArticle) { - content = renderEventContent({ hideMetadata: true }) + content = showFull ? ( + renderEventContent({ hideMetadata: true }) + ) : ( + + ) } else if (event.kind === kinds.LiveEvent || event.kind === 30312 || event.kind === 30313) { content = } else if (event.kind === ExtendedKind.GROUP_METADATA) { diff --git a/src/lib/card-event-body-blurb.ts b/src/lib/card-event-body-blurb.ts new file mode 100644 index 00000000..f7f9a15d --- /dev/null +++ b/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()}…` +} diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 67f98278..366bcb92 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -518,14 +518,15 @@ export function getLongFormArticleMetadataFromEvent(event: Event) { const tags = new Set() event.tags.forEach(([tagName, tagValue]) => { - if (tagName === 'title') { - title = tagValue - } else if (tagName === 'summary') { - summary = tagValue - } else if (tagName === 'image') { - image = tagValue - } else if (tagName === 't' && tagValue && tags.size < 6) { - tags.add(tagValue.toLowerCase()) + const n = tagName?.toLowerCase() + if (n === 'title' && tagValue?.trim()) { + title = tagValue.trim() + } else if (n === 'summary' && tagValue?.trim()) { + summary = tagValue.trim() + } else if (n === 'image' && tagValue?.trim()) { + image = tagValue.trim() + } else if (n === 't' && tagValue?.trim() && tags.size < 6) { + tags.add(tagValue.trim().replace(/^#/, '').toLowerCase()) } })