feat: improve support for long-form articles

This commit is contained in:
codytseng
2025-08-16 17:49:18 +08:00
parent 6df352a2ab
commit 06adcdb2a8
7 changed files with 101 additions and 27 deletions

View File

@@ -34,10 +34,10 @@ export default function ImageWithLightbox({
} }
return ( return (
<div className="w-fit max-w-full"> <div className="w-full">
<Image <Image
key={0} key={0}
className={cn('rounded-lg max-h-[80vh] sm:max-h-[50vh] border cursor-zoom-in', className)} className={cn('rounded-lg border cursor-zoom-in', className)}
classNames={{ classNames={{
errorPlaceholder: 'aspect-square h-[30vh]' errorPlaceholder: 'aspect-square h-[30vh]'
}} }}

View File

@@ -1,7 +1,9 @@
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
import ImageWithLightbox from '@/components/ImageWithLightbox' import ImageWithLightbox from '@/components/ImageWithLightbox'
import { Badge } from '@/components/ui/badge'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { Event } from 'nostr-tools' import { toNote, toNoteList, toProfile } from '@/lib/link'
import { ExternalLink } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
@@ -16,6 +18,7 @@ export default function LongFormArticle({
event: Event event: Event
className?: string className?: string
}) { }) {
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
return ( return (
@@ -28,15 +31,6 @@ export default function LongFormArticle({
<p className="break-words">{metadata.summary}</p> <p className="break-words">{metadata.summary}</p>
</blockquote> </blockquote>
)} )}
{metadata.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
{metadata.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="break-words">
{tag}
</Badge>
))}
</div>
)}
{metadata.image && ( {metadata.image && (
<ImageWithLightbox <ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
@@ -45,12 +39,55 @@ export default function LongFormArticle({
)} )}
<Markdown <Markdown
remarkPlugins={[remarkGfm, remarkNostr]} remarkPlugins={[remarkGfm, remarkNostr]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6) // Remove 'nostr:' prefix for rendering
}
return url
}}
components={ components={
{ {
nostr: (props) => <NostrNode {...props} />, nostr: (props) => <NostrNode {...props} />,
a: (props) => ( a: ({ href, children, ...props }) => {
<a {...props} target="_blank" rel="noreferrer noopener" className="break-words" /> if (!href) {
), return <span {...props} className="break-words" />
}
if (
href.startsWith('note1') ||
href.startsWith('nevent1') ||
href.startsWith('naddr1')
) {
return (
<SecondaryPageLink
to={toNote(href)}
className="break-words underline text-foreground"
>
{children}
</SecondaryPageLink>
)
}
if (href.startsWith('npub1') || href.startsWith('nprofile1')) {
return (
<SecondaryPageLink
to={toProfile(href)}
className="break-words underline text-foreground"
>
{children}
</SecondaryPageLink>
)
}
return (
<a
{...props}
href={href}
target="_blank"
rel="noreferrer noopener"
className="break-words inline-flex items-baseline gap-1"
>
{children} <ExternalLink className="size-3" />
</a>
)
},
p: (props) => <p {...props} className="break-words" />, p: (props) => <p {...props} className="break-words" />,
div: (props) => <div {...props} className="break-words" />, div: (props) => <div {...props} className="break-words" />,
code: (props) => <code {...props} className="break-words whitespace-pre-wrap" /> code: (props) => <code {...props} className="break-words whitespace-pre-wrap" />
@@ -59,6 +96,23 @@ export default function LongFormArticle({
> >
{event.content} {event.content}
</Markdown> </Markdown>
{metadata.tags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2">
{metadata.tags.map((tag) => (
<div
key={tag}
title={tag}
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer 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>
)}
</div> </div>
) )
} }

View File

@@ -1,7 +1,8 @@
import { Badge } from '@/components/ui/badge'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import Image from '../Image' import Image from '../Image'
@@ -13,6 +14,7 @@ export default function LongFormArticlePreview({
className?: string className?: string
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const titleComponent = <div className="text-xl font-semibold line-clamp-2">{metadata.title}</div> const titleComponent = <div className="text-xl font-semibold line-clamp-2">{metadata.title}</div>
@@ -20,9 +22,16 @@ export default function LongFormArticlePreview({
const tagsComponent = metadata.tags.length > 0 && ( const tagsComponent = metadata.tags.length > 0 && (
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
{metadata.tags.map((tag) => ( {metadata.tags.map((tag) => (
<Badge key={tag} variant="secondary"> <div
{tag} key={tag}
</Badge> className="flex items-center rounded-full text-xs px-2.5 py-0.5 bg-muted text-muted-foreground max-w-32 cursor-pointer 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> </div>
) )

View File

@@ -119,8 +119,8 @@ const NoteList = forwardRef(
subRequests.map(({ urls, filter }) => ({ subRequests.map(({ urls, filter }) => ({
urls, urls,
filter: { filter: {
...filter,
kinds: KINDS, kinds: KINDS,
...filter,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
} }
})), })),

View File

@@ -11,16 +11,21 @@ export const toNoteList = ({
hashtag, hashtag,
search, search,
externalContentId, externalContentId,
domain domain,
kinds
}: { }: {
hashtag?: string hashtag?: string
search?: string search?: string
externalContentId?: string externalContentId?: string
domain?: string domain?: string
kinds?: number[]
}) => { }) => {
const path = '/notes' const path = '/notes'
const query = new URLSearchParams() const query = new URLSearchParams()
if (hashtag) query.set('t', hashtag.toLowerCase()) if (hashtag) query.set('t', hashtag.toLowerCase())
if (kinds?.length) {
kinds.forEach((k) => query.append('k', k.toString()))
}
if (search) query.set('s', search) if (search) query.set('s', search)
if (externalContentId) query.set('i', externalContentId) if (externalContentId) query.set('i', externalContentId)
if (domain) query.set('d', domain) if (domain) query.set('d', domain)

View File

@@ -22,10 +22,12 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
const [data, setData] = useState< const [data, setData] = useState<
| { | {
type: 'hashtag' | 'search' | 'externalContent' type: 'hashtag' | 'search' | 'externalContent'
kinds?: number[]
} }
| { | {
type: 'domain' type: 'domain'
domain: string domain: string
kinds?: number[]
} }
| null | null
>(null) >(null)
@@ -34,13 +36,17 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
const searchParams = new URLSearchParams(window.location.search) const searchParams = new URLSearchParams(window.location.search)
const kinds = searchParams
.getAll('k')
.map((k) => parseInt(k))
.filter((k) => !isNaN(k))
const hashtag = searchParams.get('t') const hashtag = searchParams.get('t')
if (hashtag) { if (hashtag) {
setData({ type: 'hashtag' }) setData({ type: 'hashtag' })
setTitle(`# ${hashtag}`) setTitle(`# ${hashtag}`)
setSubRequests([ setSubRequests([
{ {
filter: { '#t': [hashtag] }, filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) },
urls: BIG_RELAY_URLS urls: BIG_RELAY_URLS
} }
]) ])
@@ -52,7 +58,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
setTitle(`${t('Search')}: ${search}`) setTitle(`${t('Search')}: ${search}`)
setSubRequests([ setSubRequests([
{ {
filter: { search }, filter: { search, ...(kinds.length > 0 ? { kinds } : {}) },
urls: SEARCHABLE_RELAY_URLS urls: SEARCHABLE_RELAY_URLS
} }
]) ])
@@ -64,7 +70,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
setTitle(externalContentId) setTitle(externalContentId)
setSubRequests([ setSubRequests([
{ {
filter: { '#I': [externalContentId] }, filter: { '#I': [externalContentId], ...(kinds.length > 0 ? { kinds } : {}) },
urls: BIG_RELAY_URLS.concat(relayList?.write || []) urls: BIG_RELAY_URLS.concat(relayList?.write || [])
} }
]) ])

View File

@@ -5,7 +5,7 @@ export type TSubRequestFilter = Omit<Filter, 'since' | 'until'> & { limit: numbe
export type TFeedSubRequest = { export type TFeedSubRequest = {
urls: string[] urls: string[]
filter: Omit<Filter, 'since' | 'until' | 'kinds'> filter: Omit<Filter, 'since' | 'until'>
} }
export type TProfile = { export type TProfile = {