feat: improve parent note preview
This commit is contained in:
34
src/components/ContentPreview/index.tsx
Normal file
34
src/components/ContentPreview/index.tsx
Normal 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>
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}} 屏蔽的用户',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user