refactor: 💨

This commit is contained in:
codytseng
2025-07-07 22:34:59 +08:00
parent c729c20771
commit 8c5cc1041b
46 changed files with 1008 additions and 879 deletions

View File

@@ -0,0 +1,242 @@
import { Button, ButtonProps } from '@/components/ui/button'
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import { Separator } from '@/components/ui/separator'
import { ExtendedKind } from '@/constants'
import { getReplaceableEventIdentifier, getSharableEventId } from '@/lib/event'
import { toChachiChat } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import clientService from '@/services/client.service'
import { ExternalLink } from 'lucide-react'
import { Event, kinds, nip19 } from 'nostr-tools'
import { Dispatch, SetStateAction, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
const clients: Record<string, { name: string; getUrl: (id: string) => string }> = {
nosta: {
name: 'Nosta',
getUrl: (id: string) => `https://nosta.me/${id}`
},
snort: {
name: 'Snort',
getUrl: (id: string) => `https://snort.social/${id}`
},
olas: {
name: 'Olas',
getUrl: (id: string) => `https://olas.app/e/${id}`
},
primal: {
name: 'Primal',
getUrl: (id: string) => `https://primal.net/e/${id}`
},
nostrudel: {
name: 'Nostrudel',
getUrl: (id: string) => `https://nostrudel.ninja/l/${id}`
},
nostter: {
name: 'Nostter',
getUrl: (id: string) => `https://nostter.app/${id}`
},
coracle: {
name: 'Coracle',
getUrl: (id: string) => `https://coracle.social/${id}`
},
iris: {
name: 'Iris',
getUrl: (id: string) => `https://iris.to/${id}`
},
lumilumi: {
name: 'Lumilumi',
getUrl: (id: string) => `https://lumilumi.app/${id}`
},
zapStream: {
name: 'zap.stream',
getUrl: (id: string) => `https://zap.stream/${id}`
},
yakihonne: {
name: 'YakiHonne',
getUrl: (id: string) => `https://yakihonne.com/${id}`
},
habla: {
name: 'Habla',
getUrl: (id: string) => `https://habla.news/a/${id}`
},
pareto: {
name: 'Pareto',
getUrl: (id: string) => `https://pareto.space/a/${id}`
},
njump: {
name: 'Njump',
getUrl: (id: string) => `https://njump.me/${id}`
}
}
export default function ClientSelect({
event,
originalNoteId,
...props
}: ButtonProps & {
event?: Event
originalNoteId?: string
}) {
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
const { t } = useTranslation()
const supportedClients = useMemo(() => {
let kind: number | undefined
if (event) {
kind = event.kind
} else if (originalNoteId) {
try {
const pointer = nip19.decode(originalNoteId)
if (pointer.type === 'naddr') {
kind = pointer.data.kind
}
} catch (error) {
console.error('Failed to decode NIP-19 pointer:', error)
return ['njump']
}
}
if (!kind) {
return ['njump']
}
switch (kind) {
case kinds.LongFormArticle:
case kinds.DraftLong:
return ['yakihonne', 'coracle', 'habla', 'lumilumi', 'pareto', 'njump']
case kinds.LiveEvent:
return ['zapStream', 'nostrudel', 'njump']
case kinds.Date:
case kinds.Time:
return ['coracle', 'njump']
case kinds.CommunityDefinition:
return ['coracle', 'snort', 'njump']
default:
return ['njump']
}
}, [event])
if (!originalNoteId && !event) {
return null
}
const content = (
<div className="space-y-2">
{event?.kind === ExtendedKind.GROUP_METADATA ? (
<RelayBasedGroupChatSelector
event={event}
originalNoteId={originalNoteId}
setOpen={setOpen}
/>
) : (
supportedClients.map((clientId) => {
const client = clients[clientId]
if (!client) return null
return (
<ClientSelectItem
key={clientId}
onClick={() => setOpen(false)}
href={client.getUrl(originalNoteId ?? getSharableEventId(event!))}
name={client.name}
/>
)
})
)}
<Separator />
<Button
variant="ghost"
className="w-full py-6 font-semibold"
onClick={() => {
navigator.clipboard.writeText(originalNoteId ?? getSharableEventId(event!))
setOpen(false)
}}
>
{t('Copy event ID')}
</Button>
</div>
)
if (isSmallScreen) {
return (
<div onClick={(e) => e.stopPropagation()}>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button {...props}>
<ExternalLink /> {t('Open in another client')}
</Button>
</DrawerTrigger>
<DrawerContent>{content}</DrawerContent>
</Drawer>
</div>
)
}
return (
<div onClick={(e) => e.stopPropagation()}>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button {...props}>
<ExternalLink /> {t('Open in another client')}
</Button>
</DialogTrigger>
<DialogContent className="px-8" onOpenAutoFocus={(e) => e.preventDefault()}>
{content}
</DialogContent>
</Dialog>
</div>
)
}
function RelayBasedGroupChatSelector({
event,
originalNoteId,
setOpen
}: {
event: Event
setOpen: Dispatch<SetStateAction<boolean>>
originalNoteId?: string
}) {
const { relay, id } = useMemo(() => {
let relay: string | undefined
if (originalNoteId) {
const pointer = nip19.decode(originalNoteId)
if (pointer.type === 'naddr' && pointer.data.relays?.length) {
relay = pointer.data.relays[0]
}
}
if (!relay) {
relay = clientService.getEventHint(event.id)
}
return { relay, id: getReplaceableEventIdentifier(event) }
}, [event, originalNoteId])
return (
<ClientSelectItem
onClick={() => setOpen(false)}
href={toChachiChat(relay, id)}
name="Chachi Chat"
/>
)
}
function ClientSelectItem({
onClick,
href,
name
}: {
onClick: () => void
href: string
name: string
}) {
return (
<Button asChild variant="ghost" className="w-full py-6 font-semibold" onClick={onClick}>
<a href={href} target="_blank" rel="noopener noreferrer">
{name}
</a>
</Button>
)
}

View File

@@ -0,0 +1,24 @@
import { getCommunityDefinition } from '@/lib/event'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function CommunityDefinitionPreview({
event,
className,
onClick
}: {
event: Event
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
}) {
const { t } = useTranslation()
const metadata = useMemo(() => getCommunityDefinition(event), [event])
return (
<div className={cn('pointer-events-none', className)} onClick={onClick}>
[{t('Community')}] <span className="italic">{metadata.name}</span>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { getGroupMetadata } from '@/lib/event'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function GroupMetadataPreview({
event,
className,
onClick
}: {
event: Event
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
}) {
const { t } = useTranslation()
const metadata = useMemo(() => getGroupMetadata(event), [event])
return (
<div className={cn('pointer-events-none', className)} onClick={onClick}>
[{t('Group')}] <span className="italic">{metadata.name}</span>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { getLiveEventMetadata } from '@/lib/event'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function LiveEventPreview({
event,
className,
onClick
}: {
event: Event
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
}) {
const { t } = useTranslation()
const metadata = useMemo(() => getLiveEventMetadata(event), [event])
return (
<div className={cn('pointer-events-none', className)} onClick={onClick}>
[{t('Live event')}] <span className="italic">{metadata.title}</span>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { getLongFormArticleMetadata } from '@/lib/event'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function LongFormArticlePreview({
event,
className,
onClick
}: {
event: Event
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
}) {
const { t } = useTranslation()
const metadata = useMemo(() => getLongFormArticleMetadata(event), [event])
return (
<div className={cn('pointer-events-none', className)} onClick={onClick}>
[{t('Article')}] <span className="italic">{metadata.title}</span>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import { useTranslatedEvent } from '@/hooks'
import {
EmbeddedEmojiParser,
EmbeddedEventParser,
EmbeddedImageParser,
EmbeddedMentionParser,
EmbeddedVideoParser,
parseContent
} from '@/lib/content-parser'
import { extractEmojiInfosFromTags } from '@/lib/event'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { EmbeddedMentionText } from '../Embedded'
import Emoji from '../Emoji'
export default function NormalContentPreview({
event,
className,
onClick
}: {
event: Event
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
}) {
const { t } = useTranslation()
const translatedEvent = useTranslatedEvent(event?.id)
const nodes = useMemo(() => {
return parseContent(event.content, [
EmbeddedImageParser,
EmbeddedVideoParser,
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedEmojiParser
])
}, [event, translatedEvent])
const emojiInfos = extractEmojiInfosFromTags(event?.tags)
return (
<div className={cn('pointer-events-none', className)} onClick={onClick}>
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'image' || node.type === 'images') {
return index > 0 ? ` [${t('image')}]` : `[${t('image')}]`
}
if (node.type === 'video') {
return index > 0 ? ` [${t('video')}]` : `[${t('video')}]`
}
if (node.type === 'event') {
return index > 0 ? ` [${t('note')}]` : `[${t('note')}]`
}
if (node.type === 'mention') {
return <EmbeddedMentionText key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji key={index} emoji={emoji} />
}
})}
</div>
)
}

View File

@@ -1,19 +1,14 @@
import { useTranslatedEvent } from '@/hooks'
import {
EmbeddedEmojiParser,
EmbeddedEventParser,
EmbeddedImageParser,
EmbeddedMentionParser,
EmbeddedVideoParser,
parseContent
} from '@/lib/content-parser'
import { extractEmojiInfosFromTags } from '@/lib/event'
import { ExtendedKind } from '@/constants'
import { cn } from '@/lib/utils'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { EmbeddedMentionText } from '../Embedded'
import Emoji from '../Emoji'
import CommunityDefinitionPreview from './CommunityDefinitionPreview'
import GroupMetadataPreview from './GroupMetadataPreview'
import LiveEventPreview from './LiveEventPreview'
import LongFormArticlePreview from './LongFormArticlePreview'
import NormalContentPreview from './NormalContentPreview'
export default function ContentPreview({
event,
@@ -25,56 +20,49 @@ export default function ContentPreview({
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
}) {
const { t } = useTranslation()
const translatedEvent = useTranslatedEvent(event?.id)
const nodes = useMemo(() => {
if (!event) return [{ type: 'text', data: `[${t('Note not found')}]` }]
const { mutePubkeys } = useMuteList()
const isMuted = useMemo(
() => (event ? mutePubkeys.includes(event.pubkey) : false),
[mutePubkeys, event]
)
if (event.kind === kinds.Highlights) return []
if (!event) {
return <div className={cn('pointer-events-none', className)}>{`[${t('Note not found')}]`}</div>
}
return parseContent(translatedEvent?.content ?? event.content, [
EmbeddedImageParser,
EmbeddedVideoParser,
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedEmojiParser
])
}, [event, translatedEvent])
if (event?.kind === kinds.Highlights) {
if (isMuted) {
return (
<div className={cn('pointer-events-none italic', className)} onClick={onClick}>
{event.content}
<div className={cn('pointer-events-none', className)}>[{t('This user has been muted')}]</div>
)
}
if ([kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.PICTURE].includes(event.kind)) {
return <NormalContentPreview event={event} className={className} onClick={onClick} />
}
if (event.kind === kinds.Highlights) {
return (
<div className={cn('pointer-events-none', className)}>
[{t('Highlight')}] <span className="italic">{event.content}</span>
</div>
)
}
const emojiInfos = extractEmojiInfosFromTags(event?.tags)
if (event.kind === kinds.LongFormArticle) {
return <LongFormArticlePreview event={event} className={className} onClick={onClick} />
}
return (
<div className={cn('pointer-events-none', className)} onClick={onClick}>
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'image' || node.type === 'images') {
return index > 0 ? ` [${t('image')}]` : `[${t('image')}]`
}
if (node.type === 'video') {
return index > 0 ? ` [${t('video')}]` : `[${t('video')}]`
}
if (node.type === 'event') {
return index > 0 ? ` [${t('note')}]` : `[${t('note')}]`
}
if (node.type === 'mention') {
return <EmbeddedMentionText key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji key={index} emoji={emoji} />
}
})}
</div>
)
if (event.kind === ExtendedKind.GROUP_METADATA) {
return <GroupMetadataPreview event={event} className={className} onClick={onClick} />
}
if (event.kind === kinds.CommunityDefinition) {
return <CommunityDefinitionPreview event={event} className={className} onClick={onClick} />
}
if (event.kind === kinds.LiveEvent) {
return <LiveEventPreview event={event} className={className} onClick={onClick} />
}
return <div className={className}>[{t('Cannot handle event of kind k', { k: event.kind })}]</div>
}

View File

@@ -1,11 +1,9 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchEvent } from '@/hooks'
import { cn } from '@/lib/utils'
import { Check, Copy } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import GenericNoteCard from '../NoteCard/GenericNoteCard'
import ClientSelect from '../ClientSelect'
import MainNoteCard from '../NoteCard/MainNoteCard'
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
const { event, isFetching } = useFetchEvent(noteId)
@@ -19,7 +17,7 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className?
}
return (
<GenericNoteCard
<MainNoteCard
className={cn('w-full', className)}
event={event}
embedded
@@ -46,23 +44,12 @@ function EmbeddedNoteSkeleton({ className }: { className?: string }) {
function EmbeddedNoteNotFound({ noteId, className }: { noteId: string; className?: string }) {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
return (
<div className={cn('text-left p-2 sm:p-3 border rounded-lg', className)}>
<div className="flex flex-col items-center text-muted-foreground font-medium gap-2">
<div>{t('Sorry! The note cannot be found 😔')}</div>
<Button
onClick={(e) => {
e.stopPropagation()
navigator.clipboard.writeText(noteId)
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
}}
variant="ghost"
>
{isCopied ? <Check /> : <Copy />} {t('Copy event ID')}
</Button>
<ClientSelect variant="secondary" className="w-full mt-2" originalNoteId={noteId} />
</div>
</div>
)

View File

@@ -0,0 +1,42 @@
import { getCommunityDefinition } from '@/lib/event'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import ClientSelect from '../ClientSelect'
import Image from '../Image'
export default function CommunityDefinition({
event,
className
}: {
event: Event
className?: string
}) {
const metadata = useMemo(() => getCommunityDefinition(event), [event])
const communityNameComponent = (
<div className="text-xl font-semibold line-clamp-1">{metadata.name}</div>
)
const communityDescriptionComponent = metadata.description && (
<div className="text-sm text-muted-foreground line-clamp-2">{metadata.description}</div>
)
return (
<div className={className}>
<div className="flex gap-4">
{metadata.image && (
<Image
image={{ url: metadata.image }}
className="rounded-lg aspect-square object-cover bg-foreground h-20"
hideIfError
/>
)}
<div className="flex-1 w-0 space-y-1">
{communityNameComponent}
{communityDescriptionComponent}
</div>
</div>
<ClientSelect variant="secondary" className="w-full mt-2" event={event} />
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { getGroupMetadata } from '@/lib/event'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import ClientSelect from '../ClientSelect'
import Image from '../Image'
export default function GroupMetadata({
event,
originalNoteId,
className
}: {
event: Event
originalNoteId?: string
className?: string
}) {
const metadata = useMemo(() => getGroupMetadata(event), [event])
const groupNameComponent = (
<div className="text-xl font-semibold line-clamp-1">{metadata.name}</div>
)
const groupAboutComponent = metadata.about && (
<div className="text-sm text-muted-foreground line-clamp-2">{metadata.about}</div>
)
return (
<div className={className}>
<div className="flex gap-4">
{metadata.picture && (
<Image
image={{ url: metadata.picture }}
className="rounded-lg aspect-square object-cover bg-foreground h-20"
hideIfError
/>
)}
<div className="flex-1 w-0 space-y-1">
{groupNameComponent}
{groupAboutComponent}
</div>
</div>
<ClientSelect
variant="secondary"
className="w-full mt-2"
event={event}
originalNoteId={originalNoteId}
/>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useFetchEvent, useTranslatedEvent } from '@/hooks'
import { createFakeEvent, isSupportedKind } from '@/lib/event'
import { createFakeEvent } from '@/lib/event'
import { toNjump, toNote } from '@/lib/link'
import { isValidPubkey } from '@/lib/pubkey'
import { generateEventIdFromATag } from '@/lib/tag'
@@ -110,7 +110,7 @@ function HighlightSource({ event }: { event: Event }) {
<div className="flex items-center gap-2 text-muted-foreground">
<div className="shrink-0">{t('From')}</div>
{pubkey && <UserAvatar userId={pubkey} size="xSmall" className="cursor-pointer" />}
{referenceEvent && isSupportedKind(referenceEvent.kind) ? (
{referenceEvent ? (
<ContentPreview
className="truncate underline pointer-events-auto cursor-pointer hover:text-foreground"
event={referenceEvent}

View File

@@ -0,0 +1,80 @@
import { Badge } from '@/components/ui/badge'
import { getLiveEventMetadata } from '@/lib/event'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import ClientSelect from '../ClientSelect'
import Image from '../Image'
export default function LiveEvent({ event, className }: { event: Event; className?: string }) {
const { isSmallScreen } = useScreenSize()
const metadata = useMemo(() => getLiveEventMetadata(event), [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 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>
)
if (isSmallScreen) {
return (
<div className={className}>
{metadata.image && (
<Image
image={{ url: metadata.image }}
className="w-full aspect-video object-cover rounded-lg"
hideIfError
/>
)}
<div className="space-y-1">
{titleComponent}
{liveStatusComponent}
{summaryComponent}
{tagsComponent}
<ClientSelect variant="secondary" className="w-full mt-2" event={event} />
</div>
</div>
)
}
return (
<div className={className}>
<div className="flex gap-4">
{metadata.image && (
<Image
image={{ url: metadata.image }}
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
hideIfError
/>
)}
<div className="flex-1 w-0 space-y-1">
{titleComponent}
{liveStatusComponent}
{summaryComponent}
{tagsComponent}
</div>
</div>
<ClientSelect variant="secondary" className="w-full mt-2" event={event} />
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { Badge } from '@/components/ui/badge'
import { getLongFormArticleMetadata } from '@/lib/event'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import ClientSelect from '../ClientSelect'
import Image from '../Image'
export default function LongFormArticle({
event,
className
}: {
event: Event
className?: string
}) {
const { isSmallScreen } = useScreenSize()
const metadata = useMemo(() => getLongFormArticleMetadata(event), [event])
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>
)
if (isSmallScreen) {
return (
<div className={className}>
{metadata.image && (
<Image
image={{ url: metadata.image }}
className="w-full aspect-video object-cover rounded-lg"
hideIfError
/>
)}
<div className="space-y-1">
{titleComponent}
{summaryComponent}
{tagsComponent}
<ClientSelect variant="secondary" className="w-full mt-2" event={event} />
</div>
</div>
)
}
return (
<div className={className}>
<div className="flex gap-4">
{metadata.image && (
<Image
image={{ url: metadata.image }}
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
hideIfError
/>
)}
<div className="flex-1 w-0 space-y-1">
{titleComponent}
{summaryComponent}
{tagsComponent}
</div>
</div>
<ClientSelect variant="secondary" className="w-full mt-2" event={event} />
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { Button } from '@/components/ui/button'
import { Eye } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function MutedNote({ show }: { show: () => void }) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-2 items-center text-muted-foreground font-medium my-4">
<div>{t('This user has been muted')}</div>
<Button
onClick={(e) => {
e.stopPropagation()
show()
}}
variant="outline"
>
<Eye />
{t('Temporarily display this note')}
</Button>
</div>
)
}

View File

@@ -1,10 +1,7 @@
import { Button } from '@/components/ui/button'
import { getSharableEventId } from '@/lib/event'
import { toNjump } from '@/lib/link'
import { cn } from '@/lib/utils'
import { ExternalLink } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import ClientSelect from '../ClientSelect'
export function UnknownNote({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
@@ -17,16 +14,7 @@ export function UnknownNote({ event, className }: { event: Event; className?: st
)}
>
<div>{t('Cannot handle event of kind k', { k: event.kind })}</div>
<Button
onClick={(e) => {
e.stopPropagation()
window.open(toNjump(getSharableEventId(event)), '_blank')
}}
variant="outline"
>
<ExternalLink />
<div>{t('View on njump.me')}</div>
</Button>
<ClientSelect event={event} variant="secondary" />
</div>
)
}

View File

@@ -1,13 +1,14 @@
import { useSecondaryPage } from '@/PageManager'
import { ExtendedKind } from '@/constants'
import {
extractImageInfosFromEventTags,
getParentEventId,
getUsingClient,
isNsfwEvent,
isPictureEvent,
isSupportedKind
isPictureEvent
} from '@/lib/event'
import { toNote } from '@/lib/link'
import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react'
@@ -20,18 +21,25 @@ import ParentNotePreview from '../ParentNotePreview'
import TranslateButton from '../TranslateButton'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import CommunityDefinition from './CommunityDefinition'
import GroupMetadata from './GroupMetadata'
import Highlight from './Highlight'
import IValue from './IValue'
import LiveEvent from './LiveEvent'
import LongFormArticle from './LongFormArticle'
import MutedNote from './MutedNote'
import NsfwNote from './NsfwNote'
import { UnknownNote } from './UnknownNote'
export default function Note({
event,
originalNoteId,
size = 'normal',
className,
hideParentNotePreview = false
}: {
event: Event
originalNoteId?: string
size?: 'normal' | 'small'
className?: string
hideParentNotePreview?: boolean
@@ -48,14 +56,37 @@ export default function Note({
)
const usingClient = useMemo(() => getUsingClient(event), [event])
const [showNsfw, setShowNsfw] = useState(false)
const { mutePubkeys } = useMuteList()
const [showMuted, setShowMuted] = useState(false)
let content: React.ReactNode
if (!isSupportedKind(event.kind)) {
if (
![
kinds.ShortTextNote,
kinds.Highlights,
kinds.LongFormArticle,
kinds.LiveEvent,
kinds.CommunityDefinition,
ExtendedKind.GROUP_METADATA,
ExtendedKind.PICTURE,
ExtendedKind.COMMENT
].includes(event.kind)
) {
content = <UnknownNote className="mt-2" event={event} />
} else if (mutePubkeys.includes(event.pubkey) && !showMuted) {
content = <MutedNote show={() => setShowMuted(true)} />
} else if (isNsfwEvent(event) && !showNsfw) {
content = <NsfwNote show={() => setShowNsfw(true)} />
} else if (event.kind === kinds.Highlights) {
content = <Highlight className="mt-2" event={event} />
} else if (event.kind === kinds.LongFormArticle) {
content = <LongFormArticle className="mt-2" event={event} />
} else if (event.kind === kinds.LiveEvent) {
content = <LiveEvent className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.GROUP_METADATA) {
content = <GroupMetadata className="mt-2" event={event} originalNoteId={originalNoteId} />
} else if (event.kind === kinds.CommunityDefinition) {
content = <CommunityDefinition className="mt-2" event={event} />
} else {
content = <Content className="mt-2" event={event} />
}

View File

@@ -1,74 +0,0 @@
import { ExtendedKind } from '@/constants'
import { isSupportedKind } from '@/lib/event'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event, kinds } from 'nostr-tools'
import { useState } from 'react'
import GroupMetadataCard from './GroupMetadataCard'
import LiveEventCard from './LiveEventCard'
import LongFormArticleCard from './LongFormArticleCard'
import MainNoteCard from './MainNoteCard'
import MutedNoteCard from './MutedNoteCard'
import UnknownNoteCard from './UnknownNoteCard'
export default function GenericNoteCard({
event,
className,
reposter,
embedded,
originalNoteId
}: {
event: Event
className?: string
reposter?: string
embedded?: boolean
originalNoteId?: string
}) {
const [showMuted, setShowMuted] = useState(false)
const { mutePubkeys } = useMuteList()
if (mutePubkeys.includes(event.pubkey) && !showMuted) {
return (
<MutedNoteCard
event={event}
className={className}
reposter={reposter}
embedded={embedded}
show={() => setShowMuted(true)}
/>
)
}
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 === ExtendedKind.GROUP_METADATA) {
return (
<GroupMetadataCard
className={className}
event={event}
originalNoteId={originalNoteId}
embedded={embedded}
/>
)
}
return (
<UnknownNoteCard event={event} className={className} reposter={reposter} embedded={embedded} />
)
}

View File

@@ -1,151 +0,0 @@
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"
hideIfError
/>
)}
<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

@@ -1,169 +0,0 @@
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"
hideIfError
/>
)}
<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" hideIfError />
)}
</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

@@ -1,162 +0,0 @@
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"
hideIfError
/>
)}
<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="rounded-lg h-36 max-w-48" hideIfError />
)}
</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

@@ -11,12 +11,14 @@ export default function MainNoteCard({
event,
className,
reposter,
embedded
embedded,
originalNoteId
}: {
event: Event
className?: string
reposter?: string
embedded?: boolean
originalNoteId?: string
}) {
const { push } = useSecondaryPage()
@@ -25,7 +27,7 @@ export default function MainNoteCard({
className={className}
onClick={(e) => {
e.stopPropagation()
push(toNote(event))
push(toNote(originalNoteId ?? event))
}}
>
<div className={`clickable ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'py-3'}`}>
@@ -35,6 +37,7 @@ export default function MainNoteCard({
className={embedded ? '' : 'px-4'}
size={embedded ? 'small' : 'normal'}
event={event}
originalNoteId={originalNoteId}
/>
</Collapsible>
{!embedded && <NoteStats className="mt-3 px-4" event={event} />}

View File

@@ -1,63 +0,0 @@
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
import { Eye } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import { FormattedTimestamp } from '../FormattedTimestamp'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import RepostDescription from './RepostDescription'
export default function MutedNoteCard({
event,
show,
reposter,
embedded,
className
}: {
event: Event
show: () => void
reposter?: string
embedded?: boolean
className?: string
}) {
const { t } = useTranslation()
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 my-4">
<div>{t('This user has been muted')}</div>
<Button
onClick={(e) => {
e.stopPropagation()
show()
}}
variant="outline"
>
<Eye />
{t('Temporarily display this note')}
</Button>
</div>
</div>
{!embedded && <Separator />}
</div>
)
}

View File

@@ -3,7 +3,7 @@ import { useMuteList } from '@/providers/MuteListProvider'
import client from '@/services/client.service'
import { Event, kinds, nip19, verifyEvent } from 'nostr-tools'
import { useEffect, useState } from 'react'
import GenericNoteCard from './GenericNoteCard'
import MainNoteCard from './MainNoteCard'
export default function RepostNoteCard({
event,
@@ -61,5 +61,5 @@ export default function RepostNoteCard({
return null
}
return <GenericNoteCard className={className} reposter={event.pubkey} event={targetEvent} />
return <MainNoteCard className={className} reposter={event.pubkey} event={targetEvent} />
}

View File

@@ -1,45 +0,0 @@
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { FormattedTimestamp } from '../FormattedTimestamp'
import { UnknownNote } from '../Note/UnknownNote'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import RepostDescription from './RepostDescription'
export default function UnknownNoteCard({
event,
className,
embedded = false,
reposter
}: {
event: Event
className?: string
embedded?: boolean
reposter?: string
}) {
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>
<UnknownNote event={event} />
</div>
{!embedded && <Separator />}
</div>
)
}

View File

@@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event, kinds } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import GenericNoteCard from './GenericNoteCard'
import MainNoteCard from './MainNoteCard'
import RepostNoteCard from './RepostNoteCard'
export default function NoteCard({
@@ -24,7 +24,7 @@ export default function NoteCard({
<RepostNoteCard event={event} className={className} filterMutedNotes={filterMutedNotes} />
)
}
return <GenericNoteCard event={event} className={className} />
return <MainNoteCard event={event} className={className} />
}
export function NoteCardLoadingSkeleton({ isPictures }: { isPictures: boolean }) {

View File

@@ -115,7 +115,13 @@ export default function NoteList({
subRequests.push({
urls: myRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5),
filter: {
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Highlights, ExtendedKind.COMMENT],
kinds: [
kinds.ShortTextNote,
kinds.Repost,
kinds.Highlights,
ExtendedKind.COMMENT,
kinds.LongFormArticle
],
authors: [pubkey],
'#p': [author],
limit: LIMIT
@@ -124,7 +130,13 @@ export default function NoteList({
subRequests.push({
urls: targetRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5),
filter: {
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Highlights, ExtendedKind.COMMENT],
kinds: [
kinds.ShortTextNote,
kinds.Repost,
kinds.Highlights,
ExtendedKind.COMMENT,
kinds.LongFormArticle
],
authors: [author],
'#p': [pubkey],
limit: LIMIT
@@ -140,7 +152,13 @@ export default function NoteList({
kinds:
filterType === 'pictures'
? [ExtendedKind.PICTURE]
: [kinds.ShortTextNote, kinds.Repost, kinds.Highlights, ExtendedKind.COMMENT],
: [
kinds.ShortTextNote,
kinds.Repost,
kinds.Highlights,
ExtendedKind.COMMENT,
kinds.LongFormArticle
],
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
if (relayUrls.length === 0 && (_filter.authors?.length || author)) {

View File

@@ -1,6 +1,5 @@
import Image from '@/components/Image'
import { useFetchEvent } from '@/hooks'
import { isSupportedKind } from '@/lib/event'
import { toNote } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
@@ -54,7 +53,7 @@ export function ReactionNotification({
return notification.content
}, [notification])
if (!event || !eventId || !isSupportedKind(event.kind)) {
if (!event || !eventId) {
return null
}

View File

@@ -1,9 +1,6 @@
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchEvent } from '@/hooks'
import { isSupportedKind } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useMuteList } from '@/providers/MuteListProvider'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ContentPreview from '../ContentPreview'
import UserAvatar from '../UserAvatar'
@@ -18,21 +15,15 @@ export default function ParentNotePreview({
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
}) {
const { t } = useTranslation()
const { mutePubkeys } = useMuteList()
const { event, isFetching } = useFetchEvent(eventId)
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',
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-44 max-w-full text-muted-foreground',
className
)}
onClick={onClick}
>
<div className="shrink-0">{t('reply to')}</div>
<Skeleton className="w-4 h-4 rounded-full" />
@@ -43,37 +34,18 @@ export default function ParentNotePreview({
)
}
if (!event) {
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',
className
)}
>
<div className="shrink-0">{t('reply to')}</div>
<div>{`[${t('Note not found')}]`}</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',
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground',
event && 'hover:text-foreground cursor-pointer',
className
)}
onClick={onClick}
onClick={event ? onClick : undefined}
>
<div className="shrink-0">{t('reply to')}</div>
{event && <UserAvatar className="shrink-0" userId={event.pubkey} size="tiny" />}
{isMuted ? (
<div className="truncate">[{t('This user has been muted')}]</div>
) : !isSupportedKind(event.kind) ? (
<div className="truncate">[{t('Cannot handle event of kind k', { k: event.kind })}]</div>
) : (
<ContentPreview className="truncate" event={event} />
)}
<ContentPreview className="truncate" event={event} />
</div>
)
}

View File

@@ -1,11 +1,11 @@
import { ExtendedKind } from '@/constants'
import { useTranslatedEvent } from '@/hooks'
import { isSupportedKind } from '@/lib/event'
import { toTranslation } from '@/lib/link'
import { cn, detectLanguage } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useTranslationService } from '@/providers/TranslationServiceProvider'
import { Languages, Loader } from 'lucide-react'
import { Event } from 'nostr-tools'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -22,7 +22,13 @@ export default function TranslateButton({
const { translateEvent, showOriginalEvent } = useTranslationService()
const [translating, setTranslating] = useState(false)
const translatedEvent = useTranslatedEvent(event.id)
const supported = useMemo(() => isSupportedKind(event.kind), [event])
const supported = useMemo(
() =>
[kinds.ShortTextNote, kinds.Highlights, ExtendedKind.COMMENT, ExtendedKind.PICTURE].includes(
event.kind
),
[event]
)
const needTranslation = useMemo(() => {
const detected = detectLanguage(event.content)