feat: optimize the display effect of other kinds of events

This commit is contained in:
codytseng
2025-02-13 22:56:09 +08:00
parent 5e3fd93a23
commit 73b38d37e7
17 changed files with 719 additions and 66 deletions

View File

@@ -0,0 +1,56 @@
import { GROUP_METADATA_EVENT_KIND } from '@/constants'
import { isSupportedKind } from '@/lib/event'
import { Event, kinds } from 'nostr-tools'
import GroupMetadataCard from './GroupMetadataCard'
import LiveEventCard from './LiveEventCard'
import LongFormArticleCard from './LongFormArticleCard'
import MainNoteCard from './MainNoteCard'
import UnknownNoteCard from './UnknownNoteCard'
export default function GenericNoteCard({
event,
className,
reposter,
embedded,
originalNoteId
}: {
event: Event
className?: string
reposter?: string
embedded?: boolean
originalNoteId?: string
}) {
if (isSupportedKind(event.kind)) {
return (
<MainNoteCard event={event} className={className} reposter={reposter} embedded={embedded} />
)
}
if (event.kind === kinds.LongFormArticle) {
return (
<LongFormArticleCard
className={className}
reposter={reposter}
event={event}
embedded={embedded}
/>
)
}
if (event.kind === kinds.LiveEvent) {
return (
<LiveEventCard event={event} className={className} reposter={reposter} embedded={embedded} />
)
}
if (event.kind === GROUP_METADATA_EVENT_KIND) {
return (
<GroupMetadataCard
className={className}
event={event}
originalNoteId={originalNoteId}
embedded={embedded}
/>
)
}
return (
<UnknownNoteCard event={event} className={className} reposter={reposter} embedded={embedded} />
)
}

View File

@@ -0,0 +1,147 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { getSharableEventId } from '@/lib/event'
import { toChachiChat } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service'
import { Check, Copy, ExternalLink } from 'lucide-react'
import { Event, nip19 } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Image from '../Image'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import RepostDescription from './RepostDescription'
export default function GroupMetadataCard({
event,
className,
originalNoteId,
embedded = false,
reposter
}: {
event: Event
className?: string
originalNoteId?: string
embedded?: boolean
reposter?: string
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const [isCopied, setIsCopied] = useState(false)
const metadata = useMemo(() => {
let d: string | undefined
let name: string | undefined
let about: string | undefined
let picture: string | undefined
let relay: string | undefined
const tags = new Set<string>()
if (originalNoteId) {
const pointer = nip19.decode(originalNoteId)
if (pointer.type === 'naddr' && pointer.data.relays?.length) {
relay = pointer.data.relays[0]
}
}
if (!relay) {
relay = client.getEventHint(event.id)
}
event.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'name') {
name = tagValue
} else if (tagName === 'about') {
about = tagValue
} else if (tagName === 'picture') {
picture = tagValue
} else if (tagName === 't' && tagValue) {
tags.add(tagValue.toLocaleLowerCase())
} else if (tagName === 'd') {
d = tagValue
}
})
if (!name) {
name = d ?? 'no name'
}
return { d, name, about, picture, tags: Array.from(tags), relay }
}, [event, originalNoteId])
return (
<div className={cn('relative', className)}>
<div className={cn(embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3')}>
<RepostDescription reposter={reposter} />
<div className="flex items-center space-x-2">
<UserAvatar userId={event.pubkey} size={embedded ? 'small' : 'normal'} />
<div
className={`flex-1 w-0 ${embedded ? 'flex space-x-2 items-center overflow-hidden' : ''}`}
>
<Username
userId={event.pubkey}
className={cn('font-semibold flex truncate', embedded ? 'text-sm' : '')}
skeletonClassName={embedded ? 'h-3' : 'h-4'}
/>
<div className="text-xs text-muted-foreground line-clamp-1">
<FormattedTimestamp timestamp={event.created_at} />
</div>
</div>
</div>
<div className="flex gap-2 items-start mt-2">
{metadata.picture && (
<Image image={{ url: metadata.picture }} className="h-32 aspect-square rounded-lg" />
)}
<div className="flex-1 w-0 space-y-1">
<div className="text-xl font-semibold line-clamp-1">{metadata.name}</div>
{metadata.about && (
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.about}</div>
)}
{metadata.tags.length > 0 && (
<div className="mt-2 flex gap-1 flex-wrap">
{metadata.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
)}
{(!metadata.relay || !metadata.d) && (
<Button
onClick={(e) => {
e.stopPropagation()
navigator.clipboard.writeText(originalNoteId ?? getSharableEventId(event))
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
}}
variant="ghost"
>
{isCopied ? <Check /> : <Copy />} Copy group ID
</Button>
)}
</div>
</div>
</div>
{!embedded && <Separator />}
{!isSmallScreen && metadata.relay && metadata.d && (
<div
className={cn(
'absolute top-0 w-full h-full bg-muted/80 backdrop-blur-sm flex flex-col items-center justify-center cursor-pointer transition-opacity opacity-0 hover:opacity-100',
embedded ? 'rounded-lg' : ''
)}
onClick={(e) => {
e.stopPropagation()
window.open(toChachiChat(simplifyUrl(metadata.relay), metadata.d!), '_blank')
}}
>
<div className="flex gap-2 items-center font-semibold">
<ExternalLink className="size-4" /> {t('Open in a', { a: 'Chachi' })}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,168 @@
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { toZapStreamLiveEvent } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { ExternalLink } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Image from '../Image'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import RepostDescription from './RepostDescription'
export default function LiveEventCard({
event,
className,
embedded = false,
reposter
}: {
event: Event
className?: string
embedded?: boolean
reposter?: string
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const metadata = useMemo(() => {
let title: string | undefined
let summary: string | undefined
let image: string | undefined
let status: string | undefined
const tags = new Set<string>()
event.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'title') {
title = tagValue
} else if (tagName === 'summary') {
summary = tagValue
} else if (tagName === 'image') {
image = tagValue
} else if (tagName === 'status') {
status = tagValue
} else if (tagName === 't' && tagValue && tags.size < 6) {
tags.add(tagValue.toLocaleLowerCase())
}
})
if (!title) {
title = event.tags.find(tagNameEquals('d'))?.[1] ?? 'no title'
}
return { title, summary, image, status, tags: Array.from(tags) }
}, [event])
const liveStatusComponent =
metadata.status &&
(metadata.status === 'live' ? (
<Badge className="bg-green-400 hover:bg-green-400">live</Badge>
) : metadata.status === 'ended' ? (
<Badge variant="destructive">ended</Badge>
) : (
<Badge variant="secondary">{metadata.status}</Badge>
))
const userInfoComponent = (
<div className="flex items-center space-x-2">
<UserAvatar userId={event.pubkey} size={embedded ? 'small' : 'normal'} />
<div
className={`flex-1 w-0 ${embedded ? 'flex space-x-2 items-center overflow-hidden' : ''}`}
>
<div className="flex gap-2 items-center">
<Username
userId={event.pubkey}
className={cn('font-semibold flex truncate', embedded ? 'text-sm' : '')}
skeletonClassName={embedded ? 'h-3' : 'h-4'}
/>
{liveStatusComponent}
</div>
<div className="text-xs text-muted-foreground line-clamp-1">
<FormattedTimestamp timestamp={event.created_at} />
</div>
</div>
</div>
)
const titleComponent = <div className="text-xl font-semibold line-clamp-1">{metadata.title}</div>
const summaryComponent = metadata.summary && (
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.summary}</div>
)
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>
)
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
window.open(toZapStreamLiveEvent(event), '_blank')
}
if (isSmallScreen) {
return (
<div className={className}>
<div
className={cn('flex flex-col gap-2', embedded ? 'p-2 border rounded-lg' : 'px-4 py-3')}
onClick={handleClick}
>
<RepostDescription reposter={reposter} />
{userInfoComponent}
{metadata.image && (
<Image
image={{ url: metadata.image }}
className="w-full aspect-video object-cover rounded-lg"
/>
)}
<div className="space-y-1">
{titleComponent}
{summaryComponent}
{tagsComponent}
</div>
</div>
{!embedded && <Separator />}
</div>
)
}
return (
<div className={cn('relative', className)}>
<div
className={cn('flex gap-2 items-center', embedded ? 'p-3 border rounded-lg' : 'px-4 py-3')}
>
<div className="flex-1 w-0">
<RepostDescription reposter={reposter} />
{userInfoComponent}
<div className="mt-2 space-y-1">
{titleComponent}
{summaryComponent}
{tagsComponent}
</div>
</div>
{metadata.image && (
<Image image={{ url: metadata.image }} className="h-36 max-w-44 rounded-lg" />
)}
</div>
{!embedded && <Separator />}
<div
className={cn(
'absolute top-0 w-full h-full bg-muted/80 backdrop-blur-sm flex flex-col items-center justify-center cursor-pointer transition-opacity opacity-0 hover:opacity-100',
embedded ? 'rounded-lg' : ''
)}
onClick={handleClick}
>
<div className="flex gap-2 items-center font-semibold">
<ExternalLink className="size-4" /> {t('Open in a', { a: 'Zap Stream' })}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,161 @@
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { toHablaLongFormArticle } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { ExternalLink } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Image from '../Image'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import RepostDescription from './RepostDescription'
export default function LongFormArticleCard({
event,
className,
embedded = false,
reposter
}: {
event: Event
className?: string
embedded?: boolean
reposter?: string
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const metadata = useMemo(() => {
let title: string | undefined
let summary: string | undefined
let image: string | undefined
let publishDateString: string | undefined
const tags = new Set<string>()
event.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'title') {
title = tagValue
} else if (tagName === 'summary') {
summary = tagValue
} else if (tagName === 'image') {
image = tagValue
} else if (tagName === 'published_at') {
try {
const publishedAt = parseInt(tagValue)
publishDateString = !isNaN(publishedAt)
? new Date(publishedAt * 1000).toLocaleString()
: undefined
} catch {
// ignore
}
} else if (tagName === 't' && tagValue && tags.size < 6) {
tags.add(tagValue.toLocaleLowerCase())
}
})
if (!title) {
title = event.tags.find(tagNameEquals('d'))?.[1] ?? 'no title'
}
return { title, summary, image, publishDateString, tags: Array.from(tags) }
}, [event])
const userInfoComponent = (
<div className="flex items-center space-x-2">
<UserAvatar userId={event.pubkey} size={embedded ? 'small' : 'normal'} />
<div
className={`flex-1 w-0 ${embedded ? 'flex space-x-2 items-center overflow-hidden' : ''}`}
>
<Username
userId={event.pubkey}
className={cn('font-semibold flex truncate', embedded ? 'text-sm' : '')}
skeletonClassName={embedded ? 'h-3' : 'h-4'}
/>
{metadata.publishDateString && (
<div className="text-xs text-muted-foreground mt-1">{metadata.publishDateString}</div>
)}
</div>
</div>
)
const titleComponent = <div className="text-xl font-semibold line-clamp-2">{metadata.title}</div>
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>
)
const summaryComponent = metadata.summary && (
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.summary}</div>
)
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
window.open(toHablaLongFormArticle(event), '_blank')
}
if (isSmallScreen) {
return (
<div className={className}>
<div
className={cn('flex flex-col gap-2', embedded ? 'p-2 border rounded-lg' : 'px-4 py-3')}
onClick={handleClick}
>
<RepostDescription reposter={reposter} />
{userInfoComponent}
{metadata.image && (
<Image
image={{ url: metadata.image }}
className="w-full aspect-video object-cover rounded-lg"
/>
)}
<div className="space-y-1">
{titleComponent}
{tagsComponent}
{summaryComponent}
</div>
</div>
{!embedded && <Separator />}
</div>
)
}
return (
<div className={cn('relative', className)}>
<div
className={cn('flex gap-2 items-center', embedded ? 'p-3 border rounded-lg' : 'px-4 py-3')}
>
<div className="flex-1 w-0">
<RepostDescription reposter={reposter} />
{userInfoComponent}
<div className="mt-2 space-y-1">
{titleComponent}
{tagsComponent}
{summaryComponent}
</div>
</div>
{metadata.image && (
<Image image={{ url: metadata.image }} className="h-36 max-w-48 rounded-lg" />
)}
</div>
{!embedded && <Separator />}
<div
className={cn(
'absolute top-0 w-full h-full bg-muted/60 backdrop-blur-sm flex flex-col items-center justify-center cursor-pointer transition-opacity opacity-0 hover:opacity-100',
embedded ? 'rounded-lg' : ''
)}
onClick={handleClick}
>
<div className="flex gap-2 items-center font-semibold">
<ExternalLink className="size-4" /> {t('Open in a', { a: 'Habla' })}
</div>
</div>
</div>
)
}

View File

@@ -2,15 +2,12 @@ import { Separator } from '@/components/ui/separator'
import { useFetchEvent } from '@/hooks'
import { getParentEventId, getRootEventId } from '@/lib/event'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { Repeat2 } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import Note from '../Note'
import Username from '../Username'
import RepostDescription from './RepostDescription'
export default function NormalNoteCard({
export default function MainNoteCard({
event,
className,
reposter,
@@ -24,7 +21,6 @@ export default function NormalNoteCard({
const { push } = useSecondaryPage()
const { event: rootEvent } = useFetchEvent(getRootEventId(event))
const { event: parentEvent } = useFetchEvent(getParentEventId(event))
return (
<div
className={className}
@@ -48,22 +44,3 @@ export default function NormalNoteCard({
</div>
)
}
function RepostDescription({
reposter,
className
}: {
reposter?: string | null
className?: string
}) {
const { t } = useTranslation()
if (!reposter) return null
return (
<div className={cn('flex gap-1 text-sm items-center text-muted-foreground mb-1', className)}>
<Repeat2 size={16} className="shrink-0" />
<Username userId={reposter} className="font-semibold truncate" skeletonClassName="h-3" />
<div>{t('reposted')}</div>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { cn } from '@/lib/utils'
import { Repeat2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import Username from '../Username'
export default function RepostDescription({
reposter,
className
}: {
reposter?: string | null
className?: string
}) {
const { t } = useTranslation()
if (!reposter) return null
return (
<div className={cn('flex gap-1 text-sm items-center text-muted-foreground mb-1', className)}>
<Repeat2 size={16} className="shrink-0" />
<Username userId={reposter} className="font-semibold truncate" skeletonClassName="h-3" />
<div>{t('reposted')}</div>
</div>
)
}

View File

@@ -2,7 +2,7 @@ import { useMuteList } from '@/providers/MuteListProvider'
import client from '@/services/client.service'
import { Event, kinds, verifyEvent } from 'nostr-tools'
import { useMemo } from 'react'
import NormalNoteCard from './NormalNoteCard'
import GenericNoteCard from './GenericNoteCard'
export default function RepostNoteCard({
event,
@@ -17,7 +17,7 @@ export default function RepostNoteCard({
const targetEvent = useMemo(() => {
try {
const targetEvent = event.content ? (JSON.parse(event.content) as Event) : null
if (!targetEvent || !verifyEvent(targetEvent) || targetEvent.kind !== kinds.ShortTextNote) {
if (!targetEvent || !verifyEvent(targetEvent) || targetEvent.kind === kinds.Repost) {
return null
}
client.addEventToCache(targetEvent)
@@ -38,5 +38,5 @@ export default function RepostNoteCard({
return null
}
return <NormalNoteCard className={className} reposter={event.pubkey} event={targetEvent} />
return <GenericNoteCard className={className} reposter={event.pubkey} event={targetEvent} />
}

View File

@@ -0,0 +1,65 @@
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { getSharableEventId } from '@/lib/event'
import { cn } from '@/lib/utils'
import { Check, Copy } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useState } from 'react'
import RepostDescription from './RepostDescription'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import { FormattedTimestamp } from '../FormattedTimestamp'
import { useTranslation } from 'react-i18next'
export default function UnknownNoteCard({
event,
className,
embedded = false,
reposter
}: {
event: Event
className?: string
embedded?: boolean
reposter?: string
}) {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
return (
<div className={className}>
<div className={cn(embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3')}>
<RepostDescription reposter={reposter} />
<div className="flex items-center space-x-2">
<UserAvatar userId={event.pubkey} size={embedded ? 'small' : 'normal'} />
<div
className={`flex-1 w-0 ${embedded ? 'flex space-x-2 items-center overflow-hidden' : ''}`}
>
<Username
userId={event.pubkey}
className={cn('font-semibold flex truncate', embedded ? 'text-sm' : '')}
skeletonClassName={embedded ? 'h-3' : 'h-4'}
/>
<div className="text-xs text-muted-foreground line-clamp-1">
<FormattedTimestamp timestamp={event.created_at} />
</div>
</div>
</div>
<div className="flex flex-col gap-2 items-center text-muted-foreground font-medium mt-2">
<div>{t('Cannot handle event of kind k', { k: event.kind })}</div>
<Button
onClick={(e) => {
e.stopPropagation()
navigator.clipboard.writeText(getSharableEventId(event))
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
}}
variant="ghost"
>
{isCopied ? <Check /> : <Copy />} Copy event ID
</Button>
</div>
</div>
{!embedded && <Separator />}
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useMuteList } from '@/providers/MuteListProvider'
import { Event, kinds } from 'nostr-tools'
import NormalNoteCard from './NormalNoteCard'
import GenericNoteCard from './GenericNoteCard'
import RepostNoteCard from './RepostNoteCard'
export default function NoteCard({
@@ -22,5 +22,5 @@ export default function NoteCard({
<RepostNoteCard event={event} className={className} filterMutedNotes={filterMutedNotes} />
)
}
return <NormalNoteCard event={event} className={className} />
return <GenericNoteCard event={event} className={className} />
}