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()}
>
<div className="flex items-center space-x-2">
<Skeleton className="w-7 h-7 rounded-full" />
<Skeleton className="h-3 w-16 my-1" />
<Skeleton className="w-16 h-7 rounded-full" />
<Skeleton className="h-3 w-12 my-1" />
</div>
<Skeleton className="w-full h-4 my-1 mt-2" />
<Skeleton className="w-2/3 h-4 my-1" />

View File

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

View File

@@ -1,6 +1,4 @@
import { Separator } from '@/components/ui/separator'
import { useFetchEvent } from '@/hooks'
import { getParentEventId, getRootEventId } from '@/lib/event'
import { toNote } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { Event } from 'nostr-tools'
@@ -19,8 +17,6 @@ export default function MainNoteCard({
embedded?: boolean
}) {
const { push } = useSecondaryPage()
const { event: rootEvent } = useFetchEvent(getRootEventId(event))
const { event: parentEvent } = useFetchEvent(getParentEventId(event))
return (
<div
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'}`}
>
<RepostDescription reposter={reposter} />
<Note
size={embedded ? 'small' : 'normal'}
event={event}
parentEvent={parentEvent ?? rootEvent}
hideStats={embedded}
/>
<Note size={embedded ? 'small' : 'normal'} event={event} hideStats={embedded} />
</div>
{!embedded && <Separator />}
</div>

View File

@@ -335,13 +335,23 @@ function LoadingSkeleton({ isPictures }: { isPictures: boolean }) {
<div className="px-4 py-3">
<div className="flex items-center space-x-2">
<Skeleton className="w-10 h-10 rounded-full" />
<div className="space-y-1">
<Skeleton className="w-10 h-4" />
<Skeleton className="w-20 h-3" />
<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>
<Skeleton className="w-full h-5 mt-2" />
<Skeleton className="w-2/3 h-5 mt-2" />
</div>
)
}

View File

@@ -1,7 +1,6 @@
import { Skeleton } from '@/components/ui/skeleton'
import { BIG_RELAY_URLS, COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
import { useFetchEvent } from '@/hooks'
import { extractEmbeddedNotesFromContent, extractImagesFromContent } from '@/lib/event'
import { toNote } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager'
@@ -22,7 +21,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
import { embedded, embeddedNostrNpubRenderer, embeddedNostrProfileRenderer } from '../Embedded'
import ContentPreview from '../ContentPreview'
import { FormattedTimestamp } from '../FormattedTimestamp'
import UserAvatar from '../UserAvatar'
@@ -230,7 +229,7 @@ function ReactionNotification({ notification }: { notification: Event }) {
<UserAvatar userId={notification.pubkey} size="small" />
<Heart size={24} className="text-red-400" />
<div>{notification.content === '+' ? <ThumbsUp size={14} /> : notification.content}</div>
<ContentPreview event={event} />
<ContentPreview className="truncate flex-1 w-0" event={event} />
</div>
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
@@ -248,7 +247,7 @@ function ReplyNotification({ notification }: { notification: Event }) {
>
<UserAvatar userId={notification.pubkey} size="small" />
<MessageCircle size={24} className="text-blue-400" />
<ContentPreview event={notification} />
<ContentPreview className="truncate flex-1 w-0" event={notification} />
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
@@ -278,7 +277,7 @@ function RepostNotification({ notification }: { notification: Event }) {
>
<UserAvatar userId={notification.pubkey} size="small" />
<Repeat size={24} className="text-green-400" />
<ContentPreview event={event} />
<ContentPreview className="truncate flex-1 w-0" event={event} />
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
@@ -307,26 +306,10 @@ function CommentNotification({ notification }: { notification: Event }) {
>
<UserAvatar userId={notification.pubkey} size="small" />
<MessageCircle size={24} className="text-blue-400" />
<ContentPreview event={notification} />
<ContentPreview className="truncate flex-1 w-0" event={notification} />
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</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 { useMuteList } from '@/providers/MuteListProvider'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ContentPreview from '../ContentPreview'
import UserAvatar from '../UserAvatar'
export default function ParentNotePreview({
event,
isFetching = false,
className,
onClick
}: {
event: Event
event?: Event
isFetching?: boolean
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
}) {
const { t } = useTranslation()
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 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-44 max-w-full text-muted-foreground hover:text-foreground cursor-pointer',
className
)}
onClick={onClick}
>
<div className="shrink-0">{t('reply to')}</div>
<UserAvatar className="shrink-0" userId={event.pubkey} size="tiny" />
<Skeleton className="w-4 h-4 rounded-full" />
<div className="py-1 flex-1">
<Skeleton className="h-3" />
</div>
</div>
)
}
return (
<div
className={cn(
'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
)}
onClick={onClick}
>
<div className="shrink-0">{t('reply to')}</div>
{event && <UserAvatar className="shrink-0" userId={event.pubkey} size="tiny" />}
{isMuted ? (
<div className="truncate">{t('[muted]')}</div>
) : (
<div className="truncate">{event.content}</div>
<ContentPreview className="truncate" event={event} />
)}
</div>
)

View File

@@ -64,7 +64,13 @@ export function SimpleUsername({
skeletonClassName?: string
}) {
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

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: 'note',
"username's following": "{{username}}'s following",
"username's used relays": "{{username}}'s used relays",
"username's muted": "{{username}}'s muted",

View File

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

View File

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