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 (
<div className="w-fit max-w-full">
<div className="w-full">
<Image
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={{
errorPlaceholder: 'aspect-square h-[30vh]'
}}

View File

@@ -1,7 +1,9 @@
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
import ImageWithLightbox from '@/components/ImageWithLightbox'
import { Badge } from '@/components/ui/badge'
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 Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -16,6 +18,7 @@ export default function LongFormArticle({
event: Event
className?: string
}) {
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
return (
@@ -28,15 +31,6 @@ export default function LongFormArticle({
<p className="break-words">{metadata.summary}</p>
</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 && (
<ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }}
@@ -45,12 +39,55 @@ export default function LongFormArticle({
)}
<Markdown
remarkPlugins={[remarkGfm, remarkNostr]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6) // Remove 'nostr:' prefix for rendering
}
return url
}}
components={
{
nostr: (props) => <NostrNode {...props} />,
a: (props) => (
<a {...props} target="_blank" rel="noreferrer noopener" className="break-words" />
),
a: ({ href, children, ...props }) => {
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" />,
div: (props) => <div {...props} className="break-words" />,
code: (props) => <code {...props} className="break-words whitespace-pre-wrap" />
@@ -59,6 +96,23 @@ export default function LongFormArticle({
>
{event.content}
</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>
)
}

View File

@@ -1,7 +1,8 @@
import { Badge } from '@/components/ui/badge'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import Image from '../Image'
@@ -13,6 +14,7 @@ export default function LongFormArticlePreview({
className?: string
}) {
const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
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 && (
<div className="flex gap-1 flex-wrap">
{metadata.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
<div
key={tag}
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>
)

View File

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

View File

@@ -11,16 +11,21 @@ export const toNoteList = ({
hashtag,
search,
externalContentId,
domain
domain,
kinds
}: {
hashtag?: string
search?: string
externalContentId?: string
domain?: string
kinds?: number[]
}) => {
const path = '/notes'
const query = new URLSearchParams()
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 (externalContentId) query.set('i', externalContentId)
if (domain) query.set('d', domain)

View File

@@ -22,10 +22,12 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
const [data, setData] = useState<
| {
type: 'hashtag' | 'search' | 'externalContent'
kinds?: number[]
}
| {
type: 'domain'
domain: string
kinds?: number[]
}
| null
>(null)
@@ -34,13 +36,17 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
useEffect(() => {
const init = async () => {
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')
if (hashtag) {
setData({ type: 'hashtag' })
setTitle(`# ${hashtag}`)
setSubRequests([
{
filter: { '#t': [hashtag] },
filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) },
urls: BIG_RELAY_URLS
}
])
@@ -52,7 +58,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
setTitle(`${t('Search')}: ${search}`)
setSubRequests([
{
filter: { search },
filter: { search, ...(kinds.length > 0 ? { kinds } : {}) },
urls: SEARCHABLE_RELAY_URLS
}
])
@@ -64,7 +70,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
setTitle(externalContentId)
setSubRequests([
{
filter: { '#I': [externalContentId] },
filter: { '#I': [externalContentId], ...(kinds.length > 0 ? { kinds } : {}) },
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 = {
urls: string[]
filter: Omit<Filter, 'since' | 'until' | 'kinds'>
filter: Omit<Filter, 'since' | 'until'>
}
export type TProfile = {