feat: improve parent note preview

This commit is contained in:
codytseng
2025-02-24 12:39:02 +08:00
parent e6516d7acd
commit 212a4ac103
11 changed files with 167 additions and 60 deletions

View File

@@ -0,0 +1,34 @@
import { extractEmbeddedNotesFromContent, extractImagesFromContent } from '@/lib/event'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { embedded, embeddedNostrNpubRenderer, embeddedNostrProfileRenderer } from '../Embedded'
export default function ContentPreview({
event,
className
}: {
event?: Event
className?: string
}) {
const { t } = useTranslation()
const content = useMemo(() => {
if (!event) return t('Not found')
const { contentWithoutEmbeddedNotes, embeddedNotes } = extractEmbeddedNotesFromContent(
event.content
)
const { contentWithoutImages, images } = extractImagesFromContent(contentWithoutEmbeddedNotes)
const contents = [contentWithoutImages]
if (images?.length) {
contents.push(`[${t('image')}]`)
}
if (embeddedNotes.length) {
contents.push(`[${t('note')}]`)
}
return embedded(contents.join(' '), [embeddedNostrProfileRenderer, embeddedNostrNpubRenderer])
}, [event])
if (!event) return null
return <div className={cn('pointer-events-none', className)}>{content}</div>
}

View File

@@ -35,8 +35,8 @@ function EmbeddedNoteSkeleton({ className }: { className?: string }) {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Skeleton className="w-7 h-7 rounded-full" /> <Skeleton className="w-16 h-7 rounded-full" />
<Skeleton className="h-3 w-16 my-1" /> <Skeleton className="h-3 w-12 my-1" />
</div> </div>
<Skeleton className="w-full h-4 my-1 mt-2" /> <Skeleton className="w-full h-4 my-1 mt-2" />
<Skeleton className="w-2/3 h-4 my-1" /> <Skeleton className="w-2/3 h-4 my-1" />

View File

@@ -1,5 +1,6 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { getUsingClient } from '@/lib/event' import { useFetchEvent } from '@/hooks'
import { getParentEventId, getUsingClient } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
@@ -12,21 +13,27 @@ import Username from '../Username'
export default function Note({ export default function Note({
event, event,
parentEvent,
size = 'normal', size = 'normal',
className, className,
hideParentNotePreview = false,
hideStats = false, hideStats = false,
fetchNoteStats = false fetchNoteStats = false
}: { }: {
event: Event event: Event
parentEvent?: Event
size?: 'normal' | 'small' size?: 'normal' | 'small'
className?: string className?: string
hideParentNotePreview?: boolean
hideStats?: boolean hideStats?: boolean
fetchNoteStats?: boolean fetchNoteStats?: boolean
}) { }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const parentEventId = useMemo(
() => (hideParentNotePreview ? undefined : getParentEventId(event)),
[event, hideParentNotePreview]
)
const { event: parentEvent, isFetching } = useFetchEvent(parentEventId)
const usingClient = useMemo(() => getUsingClient(event), [event]) const usingClient = useMemo(() => getUsingClient(event), [event])
return ( return (
<div className={className}> <div className={className}>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -49,13 +56,14 @@ export default function Note({
</div> </div>
</div> </div>
</div> </div>
{parentEvent && ( {parentEventId && (
<ParentNotePreview <ParentNotePreview
event={parentEvent} event={parentEvent}
isFetching={isFetching}
className="mt-2" className="mt-2"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
push(toNote(parentEvent)) push(toNote(parentEventId))
}} }}
/> />
)} )}

View File

@@ -1,6 +1,4 @@
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { useFetchEvent } from '@/hooks'
import { getParentEventId, getRootEventId } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@@ -19,8 +17,6 @@ export default function MainNoteCard({
embedded?: boolean embedded?: boolean
}) { }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { event: rootEvent } = useFetchEvent(getRootEventId(event))
const { event: parentEvent } = useFetchEvent(getParentEventId(event))
return ( return (
<div <div
className={className} className={className}
@@ -33,12 +29,7 @@ export default function MainNoteCard({
className={`clickable text-left ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3'}`} className={`clickable text-left ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3'}`}
> >
<RepostDescription reposter={reposter} /> <RepostDescription reposter={reposter} />
<Note <Note size={embedded ? 'small' : 'normal'} event={event} hideStats={embedded} />
size={embedded ? 'small' : 'normal'}
event={event}
parentEvent={parentEvent ?? rootEvent}
hideStats={embedded}
/>
</div> </div>
{!embedded && <Separator />} {!embedded && <Separator />}
</div> </div>

View File

@@ -335,13 +335,23 @@ function LoadingSkeleton({ isPictures }: { isPictures: boolean }) {
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Skeleton className="w-10 h-10 rounded-full" /> <Skeleton className="w-10 h-10 rounded-full" />
<div className="space-y-1"> <div className={`flex-1 w-0`}>
<Skeleton className="w-10 h-4" /> <div className="py-1">
<Skeleton className="w-20 h-3" /> <Skeleton className="h-4 w-16" />
</div>
<div className="py-0.5">
<Skeleton className="h-3 w-12" />
</div>
</div>
</div>
<div className="pt-2">
<div className="my-1">
<Skeleton className="w-full h-4 my-1 mt-2" />
</div>
<div className="my-1">
<Skeleton className="w-2/3 h-4 my-1" />
</div> </div>
</div> </div>
<Skeleton className="w-full h-5 mt-2" />
<Skeleton className="w-2/3 h-5 mt-2" />
</div> </div>
) )
} }

View File

@@ -1,7 +1,6 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { BIG_RELAY_URLS, COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants' import { BIG_RELAY_URLS, COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
import { useFetchEvent } from '@/hooks' import { useFetchEvent } from '@/hooks'
import { extractEmbeddedNotesFromContent, extractImagesFromContent } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
@@ -22,7 +21,7 @@ import {
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh' import PullToRefresh from 'react-simple-pull-to-refresh'
import { embedded, embeddedNostrNpubRenderer, embeddedNostrProfileRenderer } from '../Embedded' import ContentPreview from '../ContentPreview'
import { FormattedTimestamp } from '../FormattedTimestamp' import { FormattedTimestamp } from '../FormattedTimestamp'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
@@ -230,7 +229,7 @@ function ReactionNotification({ notification }: { notification: Event }) {
<UserAvatar userId={notification.pubkey} size="small" /> <UserAvatar userId={notification.pubkey} size="small" />
<Heart size={24} className="text-red-400" /> <Heart size={24} className="text-red-400" />
<div>{notification.content === '+' ? <ThumbsUp size={14} /> : notification.content}</div> <div>{notification.content === '+' ? <ThumbsUp size={14} /> : notification.content}</div>
<ContentPreview event={event} /> <ContentPreview className="truncate flex-1 w-0" event={event} />
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short /> <FormattedTimestamp timestamp={notification.created_at} short />
@@ -248,7 +247,7 @@ function ReplyNotification({ notification }: { notification: Event }) {
> >
<UserAvatar userId={notification.pubkey} size="small" /> <UserAvatar userId={notification.pubkey} size="small" />
<MessageCircle size={24} className="text-blue-400" /> <MessageCircle size={24} className="text-blue-400" />
<ContentPreview event={notification} /> <ContentPreview className="truncate flex-1 w-0" event={notification} />
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short /> <FormattedTimestamp timestamp={notification.created_at} short />
</div> </div>
@@ -278,7 +277,7 @@ function RepostNotification({ notification }: { notification: Event }) {
> >
<UserAvatar userId={notification.pubkey} size="small" /> <UserAvatar userId={notification.pubkey} size="small" />
<Repeat size={24} className="text-green-400" /> <Repeat size={24} className="text-green-400" />
<ContentPreview event={event} /> <ContentPreview className="truncate flex-1 w-0" event={event} />
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short /> <FormattedTimestamp timestamp={notification.created_at} short />
</div> </div>
@@ -307,26 +306,10 @@ function CommentNotification({ notification }: { notification: Event }) {
> >
<UserAvatar userId={notification.pubkey} size="small" /> <UserAvatar userId={notification.pubkey} size="small" />
<MessageCircle size={24} className="text-blue-400" /> <MessageCircle size={24} className="text-blue-400" />
<ContentPreview event={notification} /> <ContentPreview className="truncate flex-1 w-0" event={notification} />
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short /> <FormattedTimestamp timestamp={notification.created_at} short />
</div> </div>
</div> </div>
) )
} }
function ContentPreview({ event }: { event?: Event }) {
const { t } = useTranslation()
const content = useMemo(() => {
if (!event) return null
const { contentWithoutEmbeddedNotes } = extractEmbeddedNotesFromContent(event.content)
const { contentWithoutImages, images } = extractImagesFromContent(contentWithoutEmbeddedNotes)
return embedded(contentWithoutImages + (images?.length ? `[${t('image')}]` : ''), [
embeddedNostrProfileRenderer,
embeddedNostrNpubRenderer
])
}, [event])
if (!event) return null
return <div className="truncate flex-1 w-0">{content}</div>
}

View File

@@ -1,37 +1,62 @@
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ContentPreview from '../ContentPreview'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
export default function ParentNotePreview({ export default function ParentNotePreview({
event, event,
isFetching = false,
className, className,
onClick onClick
}: { }: {
event: Event event?: Event
isFetching?: boolean
className?: string className?: string
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { mutePubkeys } = useMuteList() const { mutePubkeys } = useMuteList()
const isMuted = useMemo(() => mutePubkeys.includes(event.pubkey), [mutePubkeys, event]) const isMuted = useMemo(
() => (event ? mutePubkeys.includes(event.pubkey) : false),
[mutePubkeys, event]
)
if (isFetching) {
return (
<div
className={cn(
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-44 max-w-full text-muted-foreground hover:text-foreground cursor-pointer',
className
)}
onClick={onClick}
>
<div className="shrink-0">{t('reply to')}</div>
<Skeleton className="w-4 h-4 rounded-full" />
<div className="py-1 flex-1">
<Skeleton className="h-3" />
</div>
</div>
)
}
return ( return (
<div <div
className={cn( className={cn(
'flex space-x-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground hover:text-foreground cursor-pointer', 'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground hover:text-foreground cursor-pointer',
className className
)} )}
onClick={onClick} onClick={onClick}
> >
<div className="shrink-0">{t('reply to')}</div> <div className="shrink-0">{t('reply to')}</div>
<UserAvatar className="shrink-0" userId={event.pubkey} size="tiny" /> {event && <UserAvatar className="shrink-0" userId={event.pubkey} size="tiny" />}
{isMuted ? ( {isMuted ? (
<div className="truncate">{t('[muted]')}</div> <div className="truncate">{t('[muted]')}</div>
) : ( ) : (
<div className="truncate">{event.content}</div> <ContentPreview className="truncate" event={event} />
)} )}
</div> </div>
) )

View File

@@ -64,7 +64,13 @@ export function SimpleUsername({
skeletonClassName?: string skeletonClassName?: string
}) { }) {
const { profile } = useFetchProfile(userId) const { profile } = useFetchProfile(userId)
if (!profile) return <Skeleton className={cn('w-16 my-1', skeletonClassName)} /> if (!profile) {
return (
<div className="py-1">
<Skeleton className={cn('w-16', skeletonClassName)} />
</div>
)
}
const { username } = profile const { username } = profile

View File

@@ -49,6 +49,7 @@ export default {
'switch to dark theme': 'switch to dark theme', 'switch to dark theme': 'switch to dark theme',
'switch to system theme': 'switch to system theme', 'switch to system theme': 'switch to system theme',
Note: 'Note', Note: 'Note',
note: 'note',
"username's following": "{{username}}'s following", "username's following": "{{username}}'s following",
"username's used relays": "{{username}}'s used relays", "username's used relays": "{{username}}'s used relays",
"username's muted": "{{username}}'s muted", "username's muted": "{{username}}'s muted",

View File

@@ -49,6 +49,7 @@ export default {
'switch to dark theme': '切换到深色主题', 'switch to dark theme': '切换到深色主题',
'switch to system theme': '切换到系统主题', 'switch to system theme': '切换到系统主题',
Note: '笔记', Note: '笔记',
note: '笔记',
"username's following": '{{username}} 的关注', "username's following": '{{username}} 的关注',
"username's used relays": '{{username}} 使用的服务器', "username's used relays": '{{username}} 使用的服务器',
"username's muted": '{{username}} 屏蔽的用户', "username's muted": '{{username}} 屏蔽的用户',

View File

@@ -1,10 +1,11 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import ContentPreview from '@/components/ContentPreview'
import Nip22ReplyNoteList from '@/components/Nip22ReplyNoteList' import Nip22ReplyNoteList from '@/components/Nip22ReplyNoteList'
import Note from '@/components/Note' import Note from '@/components/Note'
import PictureNote from '@/components/PictureNote' import PictureNote from '@/components/PictureNote'
import ReplyNoteList from '@/components/ReplyNoteList' import ReplyNoteList from '@/components/ReplyNoteList'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import { SimpleUsername } from '@/components/Username'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
@@ -27,7 +28,25 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={t('Note')}> <SecondaryPageLayout ref={ref} index={index} title={t('Note')}>
<div className="px-4"> <div className="px-4">
<Skeleton className="w-10 h-10 rounded-full" /> <div className="flex items-center space-x-2">
<Skeleton className="w-10 h-10 rounded-full" />
<div className={`flex-1 w-0`}>
<div className="py-1">
<Skeleton className="h-4 w-16" />
</div>
<div className="py-0.5">
<Skeleton className="h-3 w-12" />
</div>
</div>
</div>
<div className="pt-2">
<div className="my-1">
<Skeleton className="w-full h-4 my-1 mt-2" />
</div>
<div className="my-1">
<Skeleton className="w-2/3 h-4 my-1" />
</div>
</div>
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )
@@ -55,7 +74,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
<ParentNote key={`root-note-${event.id}`} eventId={rootEventId} /> <ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />
)} )}
<ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} /> <ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} />
<Note key={`note-${event.id}`} event={event} fetchNoteStats /> <Note key={`note-${event.id}`} event={event} fetchNoteStats hideParentNotePreview />
</div> </div>
<Separator className="mb-2 mt-4" /> <Separator className="mb-2 mt-4" />
{event.kind === kinds.ShortTextNote ? ( {event.kind === kinds.ShortTextNote ? (
@@ -74,23 +93,52 @@ NotePage.displayName = 'NotePage'
export default NotePage export default NotePage
function ParentNote({ eventId }: { eventId?: string }) { function ParentNote({ eventId }: { eventId?: string }) {
const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { event } = useFetchEvent(eventId) const { event, isFetching } = useFetchEvent(eventId)
if (!event) return null if (!eventId) return null
if (isFetching) {
return (
<div>
<Card
className="flex space-x-1 p-1 items-center clickable text-sm text-muted-foreground hover:text-foreground"
onClick={() => push(toNote(eventId))}
>
<Skeleton className="shrink w-4 h-4 rounded-full" />
<div className="py-1 flex-1">
<Skeleton className="h-3" />
</div>
</Card>
<div className="ml-5 w-px h-2 bg-border" />
</div>
)
}
if (!event) {
return (
<div>
<Card className="flex p-1 items-center justify-center text-sm text-muted-foreground">
{t('Not found')}
</Card>
<div className="ml-5 w-px h-2 bg-border" />
</div>
)
}
return ( return (
<div> <div>
<Card <Card
className="flex space-x-1 p-1 items-center clickable text-sm text-muted-foreground hover:text-foreground" className="flex space-x-1 p-1 items-center clickable text-sm text-muted-foreground hover:text-foreground"
onClick={() => push(toNote(event))} onClick={() => push(toNote(eventId))}
> >
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" /> <UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
<Username <SimpleUsername
userId={event.pubkey} userId={event.pubkey}
className="font-semibold" className="font-semibold truncate shrink-0"
skeletonClassName="h-4 shrink-0" skeletonClassName="h-3 shrink-0"
/> />
<div className="truncate">{event.content}</div> <ContentPreview className="truncate" event={event} />
</Card> </Card>
<div className="ml-5 w-px h-2 bg-border" /> <div className="ml-5 w-px h-2 bg-border" />
</div> </div>