refactor: 💨
This commit is contained in:
parent
c729c20771
commit
8c5cc1041b
46 changed files with 1008 additions and 879 deletions
242
src/components/ClientSelect/index.tsx
Normal file
242
src/components/ClientSelect/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/components/ContentPreview/CommunityDefinitionPreview.tsx
Normal file
24
src/components/ContentPreview/CommunityDefinitionPreview.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/components/ContentPreview/GroupMetadataPreview.tsx
Normal file
24
src/components/ContentPreview/GroupMetadataPreview.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/components/ContentPreview/LiveEventPreview.tsx
Normal file
24
src/components/ContentPreview/LiveEventPreview.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/components/ContentPreview/LongFormArticlePreview.tsx
Normal file
24
src/components/ContentPreview/LongFormArticlePreview.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
src/components/ContentPreview/NormalContentPreview.tsx
Normal file
68
src/components/ContentPreview/NormalContentPreview.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,14 @@
|
||||||
import { useTranslatedEvent } from '@/hooks'
|
import { ExtendedKind } from '@/constants'
|
||||||
import {
|
|
||||||
EmbeddedEmojiParser,
|
|
||||||
EmbeddedEventParser,
|
|
||||||
EmbeddedImageParser,
|
|
||||||
EmbeddedMentionParser,
|
|
||||||
EmbeddedVideoParser,
|
|
||||||
parseContent
|
|
||||||
} from '@/lib/content-parser'
|
|
||||||
import { extractEmojiInfosFromTags } from '@/lib/event'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { EmbeddedMentionText } from '../Embedded'
|
import CommunityDefinitionPreview from './CommunityDefinitionPreview'
|
||||||
import Emoji from '../Emoji'
|
import GroupMetadataPreview from './GroupMetadataPreview'
|
||||||
|
import LiveEventPreview from './LiveEventPreview'
|
||||||
|
import LongFormArticlePreview from './LongFormArticlePreview'
|
||||||
|
import NormalContentPreview from './NormalContentPreview'
|
||||||
|
|
||||||
export default function ContentPreview({
|
export default function ContentPreview({
|
||||||
event,
|
event,
|
||||||
|
|
@ -25,56 +20,49 @@ export default function ContentPreview({
|
||||||
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const translatedEvent = useTranslatedEvent(event?.id)
|
const { mutePubkeys } = useMuteList()
|
||||||
const nodes = useMemo(() => {
|
const isMuted = useMemo(
|
||||||
if (!event) return [{ type: 'text', data: `[${t('Note not found')}]` }]
|
() => (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, [
|
if (isMuted) {
|
||||||
EmbeddedImageParser,
|
|
||||||
EmbeddedVideoParser,
|
|
||||||
EmbeddedEventParser,
|
|
||||||
EmbeddedMentionParser,
|
|
||||||
EmbeddedEmojiParser
|
|
||||||
])
|
|
||||||
}, [event, translatedEvent])
|
|
||||||
|
|
||||||
if (event?.kind === kinds.Highlights) {
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('pointer-events-none italic', className)} onClick={onClick}>
|
<div className={cn('pointer-events-none', className)}>[{t('This user has been muted')}]</div>
|
||||||
{event.content}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const emojiInfos = extractEmojiInfosFromTags(event?.tags)
|
if (event.kind === kinds.LongFormArticle) {
|
||||||
|
return <LongFormArticlePreview event={event} className={className} onClick={onClick} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
if (event.kind === ExtendedKind.GROUP_METADATA) {
|
||||||
<div className={cn('pointer-events-none', className)} onClick={onClick}>
|
return <GroupMetadataPreview event={event} className={className} onClick={onClick} />
|
||||||
{nodes.map((node, index) => {
|
}
|
||||||
if (node.type === 'text') {
|
|
||||||
return node.data
|
if (event.kind === kinds.CommunityDefinition) {
|
||||||
}
|
return <CommunityDefinitionPreview event={event} className={className} onClick={onClick} />
|
||||||
if (node.type === 'image' || node.type === 'images') {
|
}
|
||||||
return index > 0 ? ` [${t('image')}]` : `[${t('image')}]`
|
|
||||||
}
|
if (event.kind === kinds.LiveEvent) {
|
||||||
if (node.type === 'video') {
|
return <LiveEventPreview event={event} className={className} onClick={onClick} />
|
||||||
return index > 0 ? ` [${t('video')}]` : `[${t('video')}]`
|
}
|
||||||
}
|
|
||||||
if (node.type === 'event') {
|
return <div className={className}>[{t('Cannot handle event of kind k', { k: event.kind })}]</div>
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useFetchEvent } from '@/hooks'
|
import { useFetchEvent } from '@/hooks'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Check, Copy } from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
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 }) {
|
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
|
||||||
const { event, isFetching } = useFetchEvent(noteId)
|
const { event, isFetching } = useFetchEvent(noteId)
|
||||||
|
|
@ -19,7 +17,7 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className?
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericNoteCard
|
<MainNoteCard
|
||||||
className={cn('w-full', className)}
|
className={cn('w-full', className)}
|
||||||
event={event}
|
event={event}
|
||||||
embedded
|
embedded
|
||||||
|
|
@ -46,23 +44,12 @@ function EmbeddedNoteSkeleton({ className }: { className?: string }) {
|
||||||
|
|
||||||
function EmbeddedNoteNotFound({ noteId, className }: { noteId: string; className?: string }) {
|
function EmbeddedNoteNotFound({ noteId, className }: { noteId: string; className?: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [isCopied, setIsCopied] = useState(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('text-left p-2 sm:p-3 border rounded-lg', className)}>
|
<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 className="flex flex-col items-center text-muted-foreground font-medium gap-2">
|
||||||
<div>{t('Sorry! The note cannot be found 😔')}</div>
|
<div>{t('Sorry! The note cannot be found 😔')}</div>
|
||||||
<Button
|
<ClientSelect variant="secondary" className="w-full mt-2" originalNoteId={noteId} />
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
navigator.clipboard.writeText(noteId)
|
|
||||||
setIsCopied(true)
|
|
||||||
setTimeout(() => setIsCopied(false), 2000)
|
|
||||||
}}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
{isCopied ? <Check /> : <Copy />} {t('Copy event ID')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
42
src/components/Note/CommunityDefinition.tsx
Normal file
42
src/components/Note/CommunityDefinition.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
src/components/Note/GroupMetadata.tsx
Normal file
49
src/components/Note/GroupMetadata.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useFetchEvent, useTranslatedEvent } from '@/hooks'
|
import { useFetchEvent, useTranslatedEvent } from '@/hooks'
|
||||||
import { createFakeEvent, isSupportedKind } from '@/lib/event'
|
import { createFakeEvent } from '@/lib/event'
|
||||||
import { toNjump, toNote } from '@/lib/link'
|
import { toNjump, toNote } from '@/lib/link'
|
||||||
import { isValidPubkey } from '@/lib/pubkey'
|
import { isValidPubkey } from '@/lib/pubkey'
|
||||||
import { generateEventIdFromATag } from '@/lib/tag'
|
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="flex items-center gap-2 text-muted-foreground">
|
||||||
<div className="shrink-0">{t('From')}</div>
|
<div className="shrink-0">{t('From')}</div>
|
||||||
{pubkey && <UserAvatar userId={pubkey} size="xSmall" className="cursor-pointer" />}
|
{pubkey && <UserAvatar userId={pubkey} size="xSmall" className="cursor-pointer" />}
|
||||||
{referenceEvent && isSupportedKind(referenceEvent.kind) ? (
|
{referenceEvent ? (
|
||||||
<ContentPreview
|
<ContentPreview
|
||||||
className="truncate underline pointer-events-auto cursor-pointer hover:text-foreground"
|
className="truncate underline pointer-events-auto cursor-pointer hover:text-foreground"
|
||||||
event={referenceEvent}
|
event={referenceEvent}
|
||||||
|
|
|
||||||
80
src/components/Note/LiveEvent.tsx
Normal file
80
src/components/Note/LiveEvent.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
src/components/Note/LongFormArticle.tsx
Normal file
74
src/components/Note/LongFormArticle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
src/components/Note/MutedNote.tsx
Normal file
23
src/components/Note/MutedNote.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 { cn } from '@/lib/utils'
|
||||||
import { ExternalLink } from 'lucide-react'
|
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import ClientSelect from '../ClientSelect'
|
||||||
|
|
||||||
export function UnknownNote({ event, className }: { event: Event; className?: string }) {
|
export function UnknownNote({ event, className }: { event: Event; className?: string }) {
|
||||||
const { t } = useTranslation()
|
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>
|
<div>{t('Cannot handle event of kind k', { k: event.kind })}</div>
|
||||||
<Button
|
<ClientSelect event={event} variant="secondary" />
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
window.open(toNjump(getSharableEventId(event)), '_blank')
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<ExternalLink />
|
|
||||||
<div>{t('View on njump.me')}</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { ExtendedKind } from '@/constants'
|
||||||
import {
|
import {
|
||||||
extractImageInfosFromEventTags,
|
extractImageInfosFromEventTags,
|
||||||
getParentEventId,
|
getParentEventId,
|
||||||
getUsingClient,
|
getUsingClient,
|
||||||
isNsfwEvent,
|
isNsfwEvent,
|
||||||
isPictureEvent,
|
isPictureEvent
|
||||||
isSupportedKind
|
|
||||||
} from '@/lib/event'
|
} from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
@ -20,18 +21,25 @@ import ParentNotePreview from '../ParentNotePreview'
|
||||||
import TranslateButton from '../TranslateButton'
|
import TranslateButton from '../TranslateButton'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
import CommunityDefinition from './CommunityDefinition'
|
||||||
|
import GroupMetadata from './GroupMetadata'
|
||||||
import Highlight from './Highlight'
|
import Highlight from './Highlight'
|
||||||
import IValue from './IValue'
|
import IValue from './IValue'
|
||||||
|
import LiveEvent from './LiveEvent'
|
||||||
|
import LongFormArticle from './LongFormArticle'
|
||||||
|
import MutedNote from './MutedNote'
|
||||||
import NsfwNote from './NsfwNote'
|
import NsfwNote from './NsfwNote'
|
||||||
import { UnknownNote } from './UnknownNote'
|
import { UnknownNote } from './UnknownNote'
|
||||||
|
|
||||||
export default function Note({
|
export default function Note({
|
||||||
event,
|
event,
|
||||||
|
originalNoteId,
|
||||||
size = 'normal',
|
size = 'normal',
|
||||||
className,
|
className,
|
||||||
hideParentNotePreview = false
|
hideParentNotePreview = false
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
|
originalNoteId?: string
|
||||||
size?: 'normal' | 'small'
|
size?: 'normal' | 'small'
|
||||||
className?: string
|
className?: string
|
||||||
hideParentNotePreview?: boolean
|
hideParentNotePreview?: boolean
|
||||||
|
|
@ -48,14 +56,37 @@ export default function Note({
|
||||||
)
|
)
|
||||||
const usingClient = useMemo(() => getUsingClient(event), [event])
|
const usingClient = useMemo(() => getUsingClient(event), [event])
|
||||||
const [showNsfw, setShowNsfw] = useState(false)
|
const [showNsfw, setShowNsfw] = useState(false)
|
||||||
|
const { mutePubkeys } = useMuteList()
|
||||||
|
const [showMuted, setShowMuted] = useState(false)
|
||||||
|
|
||||||
let content: React.ReactNode
|
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} />
|
content = <UnknownNote className="mt-2" event={event} />
|
||||||
|
} else if (mutePubkeys.includes(event.pubkey) && !showMuted) {
|
||||||
|
content = <MutedNote show={() => setShowMuted(true)} />
|
||||||
} else if (isNsfwEvent(event) && !showNsfw) {
|
} else if (isNsfwEvent(event) && !showNsfw) {
|
||||||
content = <NsfwNote show={() => setShowNsfw(true)} />
|
content = <NsfwNote show={() => setShowNsfw(true)} />
|
||||||
} else if (event.kind === kinds.Highlights) {
|
} else if (event.kind === kinds.Highlights) {
|
||||||
content = <Highlight className="mt-2" event={event} />
|
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 {
|
} else {
|
||||||
content = <Content className="mt-2" event={event} />
|
content = <Content className="mt-2" event={event} />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -11,12 +11,14 @@ export default function MainNoteCard({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
reposter,
|
reposter,
|
||||||
embedded
|
embedded,
|
||||||
|
originalNoteId
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
className?: string
|
className?: string
|
||||||
reposter?: string
|
reposter?: string
|
||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
|
originalNoteId?: string
|
||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
|
|
||||||
|
|
@ -25,7 +27,7 @@ export default function MainNoteCard({
|
||||||
className={className}
|
className={className}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
push(toNote(event))
|
push(toNote(originalNoteId ?? event))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={`clickable ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'py-3'}`}>
|
<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'}
|
className={embedded ? '' : 'px-4'}
|
||||||
size={embedded ? 'small' : 'normal'}
|
size={embedded ? 'small' : 'normal'}
|
||||||
event={event}
|
event={event}
|
||||||
|
originalNoteId={originalNoteId}
|
||||||
/>
|
/>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
{!embedded && <NoteStats className="mt-3 px-4" event={event} />}
|
{!embedded && <NoteStats className="mt-3 px-4" event={event} />}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { Event, kinds, nip19, verifyEvent } from 'nostr-tools'
|
import { Event, kinds, nip19, verifyEvent } from 'nostr-tools'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import GenericNoteCard from './GenericNoteCard'
|
import MainNoteCard from './MainNoteCard'
|
||||||
|
|
||||||
export default function RepostNoteCard({
|
export default function RepostNoteCard({
|
||||||
event,
|
event,
|
||||||
|
|
@ -61,5 +61,5 @@ export default function RepostNoteCard({
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return <GenericNoteCard className={className} reposter={event.pubkey} event={targetEvent} />
|
return <MainNoteCard className={className} reposter={event.pubkey} event={targetEvent} />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import GenericNoteCard from './GenericNoteCard'
|
import MainNoteCard from './MainNoteCard'
|
||||||
import RepostNoteCard from './RepostNoteCard'
|
import RepostNoteCard from './RepostNoteCard'
|
||||||
|
|
||||||
export default function NoteCard({
|
export default function NoteCard({
|
||||||
|
|
@ -24,7 +24,7 @@ export default function NoteCard({
|
||||||
<RepostNoteCard event={event} className={className} filterMutedNotes={filterMutedNotes} />
|
<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 }) {
|
export function NoteCardLoadingSkeleton({ isPictures }: { isPictures: boolean }) {
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,13 @@ export default function NoteList({
|
||||||
subRequests.push({
|
subRequests.push({
|
||||||
urls: myRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5),
|
urls: myRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5),
|
||||||
filter: {
|
filter: {
|
||||||
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Highlights, ExtendedKind.COMMENT],
|
kinds: [
|
||||||
|
kinds.ShortTextNote,
|
||||||
|
kinds.Repost,
|
||||||
|
kinds.Highlights,
|
||||||
|
ExtendedKind.COMMENT,
|
||||||
|
kinds.LongFormArticle
|
||||||
|
],
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
'#p': [author],
|
'#p': [author],
|
||||||
limit: LIMIT
|
limit: LIMIT
|
||||||
|
|
@ -124,7 +130,13 @@ export default function NoteList({
|
||||||
subRequests.push({
|
subRequests.push({
|
||||||
urls: targetRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5),
|
urls: targetRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5),
|
||||||
filter: {
|
filter: {
|
||||||
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Highlights, ExtendedKind.COMMENT],
|
kinds: [
|
||||||
|
kinds.ShortTextNote,
|
||||||
|
kinds.Repost,
|
||||||
|
kinds.Highlights,
|
||||||
|
ExtendedKind.COMMENT,
|
||||||
|
kinds.LongFormArticle
|
||||||
|
],
|
||||||
authors: [author],
|
authors: [author],
|
||||||
'#p': [pubkey],
|
'#p': [pubkey],
|
||||||
limit: LIMIT
|
limit: LIMIT
|
||||||
|
|
@ -140,7 +152,13 @@ export default function NoteList({
|
||||||
kinds:
|
kinds:
|
||||||
filterType === 'pictures'
|
filterType === 'pictures'
|
||||||
? [ExtendedKind.PICTURE]
|
? [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
|
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
|
||||||
}
|
}
|
||||||
if (relayUrls.length === 0 && (_filter.authors?.length || author)) {
|
if (relayUrls.length === 0 && (_filter.authors?.length || author)) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import Image from '@/components/Image'
|
import Image from '@/components/Image'
|
||||||
import { useFetchEvent } from '@/hooks'
|
import { useFetchEvent } from '@/hooks'
|
||||||
import { isSupportedKind } from '@/lib/event'
|
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
import { tagNameEquals } from '@/lib/tag'
|
import { tagNameEquals } from '@/lib/tag'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
@ -54,7 +53,7 @@ export function ReactionNotification({
|
||||||
return notification.content
|
return notification.content
|
||||||
}, [notification])
|
}, [notification])
|
||||||
|
|
||||||
if (!event || !eventId || !isSupportedKind(event.kind)) {
|
if (!event || !eventId) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useFetchEvent } from '@/hooks'
|
import { useFetchEvent } from '@/hooks'
|
||||||
import { isSupportedKind } from '@/lib/event'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ContentPreview from '../ContentPreview'
|
import ContentPreview from '../ContentPreview'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
|
|
@ -18,21 +15,15 @@ export default function ParentNotePreview({
|
||||||
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { mutePubkeys } = useMuteList()
|
|
||||||
const { event, isFetching } = useFetchEvent(eventId)
|
const { event, isFetching } = useFetchEvent(eventId)
|
||||||
const isMuted = useMemo(
|
|
||||||
() => (event ? mutePubkeys.includes(event.pubkey) : false),
|
|
||||||
[mutePubkeys, event]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
|
||||||
>
|
>
|
||||||
<div className="shrink-0">{t('reply to')}</div>
|
<div className="shrink-0">{t('reply to')}</div>
|
||||||
<Skeleton className="w-4 h-4 rounded-full" />
|
<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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={event ? onClick : undefined}
|
||||||
>
|
>
|
||||||
<div className="shrink-0">{t('reply to')}</div>
|
<div className="shrink-0">{t('reply to')}</div>
|
||||||
{event && <UserAvatar className="shrink-0" userId={event.pubkey} size="tiny" />}
|
{event && <UserAvatar className="shrink-0" userId={event.pubkey} size="tiny" />}
|
||||||
{isMuted ? (
|
<ContentPreview className="truncate" event={event} />
|
||||||
<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} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
|
import { ExtendedKind } from '@/constants'
|
||||||
import { useTranslatedEvent } from '@/hooks'
|
import { useTranslatedEvent } from '@/hooks'
|
||||||
import { isSupportedKind } from '@/lib/event'
|
|
||||||
import { toTranslation } from '@/lib/link'
|
import { toTranslation } from '@/lib/link'
|
||||||
import { cn, detectLanguage } from '@/lib/utils'
|
import { cn, detectLanguage } from '@/lib/utils'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
||||||
import { Languages, Loader } from 'lucide-react'
|
import { Languages, Loader } from 'lucide-react'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
@ -22,7 +22,13 @@ export default function TranslateButton({
|
||||||
const { translateEvent, showOriginalEvent } = useTranslationService()
|
const { translateEvent, showOriginalEvent } = useTranslationService()
|
||||||
const [translating, setTranslating] = useState(false)
|
const [translating, setTranslating] = useState(false)
|
||||||
const translatedEvent = useTranslatedEvent(event.id)
|
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 needTranslation = useMemo(() => {
|
||||||
const detected = detectLanguage(event.content)
|
const detected = detectLanguage(event.content)
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,11 @@ export default {
|
||||||
Translate: 'ترجمة',
|
Translate: 'ترجمة',
|
||||||
'Show original': 'عرض الأصل',
|
'Show original': 'عرض الأصل',
|
||||||
Website: 'الموقع الإلكتروني',
|
Website: 'الموقع الإلكتروني',
|
||||||
'Hide untrusted notes': 'إخفاء الملاحظات غير الموثوقة'
|
'Hide untrusted notes': 'إخفاء الملاحظات غير الموثوقة',
|
||||||
|
'Open in another client': 'فتح في عميل آخر',
|
||||||
|
Community: 'المجتمع',
|
||||||
|
Group: 'المجموعة',
|
||||||
|
'Live event': 'حدث مباشر',
|
||||||
|
Article: 'مقالة'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,11 @@ export default {
|
||||||
Translate: 'Übersetzen',
|
Translate: 'Übersetzen',
|
||||||
'Show original': 'Original anzeigen',
|
'Show original': 'Original anzeigen',
|
||||||
Website: 'Website',
|
Website: 'Website',
|
||||||
'Hide untrusted notes': 'Untrusted Notizen ausblenden'
|
'Hide untrusted notes': 'Untrusted Notizen ausblenden',
|
||||||
|
'Open in another client': 'In anderem Client öffnen',
|
||||||
|
Community: 'Community',
|
||||||
|
Group: 'Gruppe',
|
||||||
|
'Live event': 'Live-Event',
|
||||||
|
Article: 'Artikel'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,11 @@ export default {
|
||||||
Translate: 'Translate',
|
Translate: 'Translate',
|
||||||
'Show original': 'Show original',
|
'Show original': 'Show original',
|
||||||
Website: 'Website',
|
Website: 'Website',
|
||||||
'Hide untrusted notes': 'Hide untrusted notes'
|
'Hide untrusted notes': 'Hide untrusted notes',
|
||||||
|
'Open in another client': 'Open in another client',
|
||||||
|
Community: 'Community',
|
||||||
|
Group: 'Group',
|
||||||
|
'Live event': 'Live event',
|
||||||
|
Article: 'Article'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,11 @@ export default {
|
||||||
Translate: 'Traducir',
|
Translate: 'Traducir',
|
||||||
'Show original': 'Mostrar original',
|
'Show original': 'Mostrar original',
|
||||||
Website: 'Sitio web',
|
Website: 'Sitio web',
|
||||||
'Hide untrusted notes': 'Ocultar notas no confiables'
|
'Hide untrusted notes': 'Ocultar notas no confiables',
|
||||||
|
'Open in another client': 'Abrir en otro cliente',
|
||||||
|
Community: 'Comunidad',
|
||||||
|
Group: 'Grupo',
|
||||||
|
'Live event': 'Evento en vivo',
|
||||||
|
Article: 'Artículo'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,11 @@ export default {
|
||||||
Translate: 'Traduire',
|
Translate: 'Traduire',
|
||||||
'Show original': 'Afficher l’original',
|
'Show original': 'Afficher l’original',
|
||||||
Website: 'Site Web',
|
Website: 'Site Web',
|
||||||
'Hide untrusted notes': 'Cacher les notes non fiables'
|
'Hide untrusted notes': 'Cacher les notes non fiables',
|
||||||
|
'Open in another client': 'Ouvrir dans un autre client',
|
||||||
|
Community: 'Communauté',
|
||||||
|
Group: 'Groupe',
|
||||||
|
'Live event': 'Événement en direct',
|
||||||
|
Article: 'Article'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,11 @@ export default {
|
||||||
Translate: 'Traduci',
|
Translate: 'Traduci',
|
||||||
'Show original': 'Mostra originale',
|
'Show original': 'Mostra originale',
|
||||||
Website: 'Sito web',
|
Website: 'Sito web',
|
||||||
'Hide untrusted notes': 'Nascondi note non fidate'
|
'Hide untrusted notes': 'Nascondi note non fidate',
|
||||||
|
'Open in another client': 'Apri in un altro client',
|
||||||
|
Community: 'Comunità',
|
||||||
|
Group: 'Gruppo',
|
||||||
|
'Live event': 'Evento dal vivo',
|
||||||
|
Article: 'Articolo'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,11 @@ export default {
|
||||||
Translate: '翻訳',
|
Translate: '翻訳',
|
||||||
'Show original': '原文を表示',
|
'Show original': '原文を表示',
|
||||||
Website: 'ウェブサイト',
|
Website: 'ウェブサイト',
|
||||||
'Hide untrusted notes': '信頼されていないノートを非表示'
|
'Hide untrusted notes': '信頼されていないノートを非表示',
|
||||||
|
'Open in another client': '別のクライアントで開く',
|
||||||
|
Community: 'コミュニティ',
|
||||||
|
Group: 'グループ',
|
||||||
|
'Live event': 'ライブイベント',
|
||||||
|
Article: '記事'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,11 @@ export default {
|
||||||
Translate: '번역',
|
Translate: '번역',
|
||||||
'Show original': '원본 보기',
|
'Show original': '원본 보기',
|
||||||
Website: '웹사이트',
|
Website: '웹사이트',
|
||||||
'Hide untrusted notes': '신뢰하지 않는 노트 숨기기'
|
'Hide untrusted notes': '신뢰하지 않는 노트 숨기기',
|
||||||
|
'Open in another client': '다른 클라이언트에서 열기',
|
||||||
|
Community: '커뮤니티',
|
||||||
|
Group: '그룹',
|
||||||
|
'Live event': '라이브 이벤트',
|
||||||
|
Article: '기사'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,11 @@ export default {
|
||||||
Translate: 'Przetłumacz',
|
Translate: 'Przetłumacz',
|
||||||
'Show original': 'Pokaż oryginał',
|
'Show original': 'Pokaż oryginał',
|
||||||
Website: 'Strona internetowa',
|
Website: 'Strona internetowa',
|
||||||
'Hide untrusted notes': 'Ukryj wpisy od nieznanych użytkowników'
|
'Hide untrusted notes': 'Ukryj wpisy od nieznanych użytkowników',
|
||||||
|
'Open in another client': 'Otwórz w innym kliencie',
|
||||||
|
Community: 'Społeczność',
|
||||||
|
Group: 'Grupa',
|
||||||
|
'Live event': 'Wydarzenie na żywo',
|
||||||
|
Article: 'Artykuł'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,11 @@ export default {
|
||||||
Translate: 'Traduzir',
|
Translate: 'Traduzir',
|
||||||
'Show original': 'Mostrar original',
|
'Show original': 'Mostrar original',
|
||||||
Website: 'Website',
|
Website: 'Website',
|
||||||
'Hide untrusted notes': 'Ocultar notas não confiáveis'
|
'Hide untrusted notes': 'Ocultar notas não confiáveis',
|
||||||
|
'Open in another client': 'Abrir em outro cliente',
|
||||||
|
Community: 'Comunidade',
|
||||||
|
Group: 'Grupo',
|
||||||
|
'Live event': 'Evento ao vivo',
|
||||||
|
Article: 'Artigo'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,11 @@ export default {
|
||||||
Translate: 'Traduzir',
|
Translate: 'Traduzir',
|
||||||
'Show original': 'Mostrar original',
|
'Show original': 'Mostrar original',
|
||||||
Website: 'Website',
|
Website: 'Website',
|
||||||
'Hide untrusted notes': 'Esconder notas não confiáveis'
|
'Hide untrusted notes': 'Esconder notas não confiáveis',
|
||||||
|
'Open in another client': 'Abrir em outro cliente',
|
||||||
|
Community: 'Comunidade',
|
||||||
|
Group: 'Grupo',
|
||||||
|
'Live event': 'Evento ao vivo',
|
||||||
|
Article: 'Artigo'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,11 @@ export default {
|
||||||
Translate: 'Перевести',
|
Translate: 'Перевести',
|
||||||
'Show original': 'Показать оригинал',
|
'Show original': 'Показать оригинал',
|
||||||
Website: 'Веб-сайт',
|
Website: 'Веб-сайт',
|
||||||
'Hide untrusted notes': 'Скрыть недоверенные заметки'
|
'Hide untrusted notes': 'Скрыть недоверенные заметки',
|
||||||
|
'Open in another client': 'Открыть в другом клиенте',
|
||||||
|
Community: 'Сообщество',
|
||||||
|
Group: 'Группа',
|
||||||
|
'Live event': 'Живое событие',
|
||||||
|
Article: 'Статья'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,11 @@ export default {
|
||||||
Translate: 'แปล',
|
Translate: 'แปล',
|
||||||
'Show original': 'แสดงต้นฉบับ',
|
'Show original': 'แสดงต้นฉบับ',
|
||||||
Website: 'เว็บไซต์',
|
Website: 'เว็บไซต์',
|
||||||
'Hide untrusted notes': 'ซ่อนโน้ตที่ไม่น่าเชื่อถือ'
|
'Hide untrusted notes': 'ซ่อนโน้ตที่ไม่น่าเชื่อถือ',
|
||||||
|
'Open in another client': 'เปิดในไคลเอนต์อื่น',
|
||||||
|
Community: 'ชุมชน',
|
||||||
|
Group: 'กลุ่ม',
|
||||||
|
'Live event': 'เหตุการณ์สด',
|
||||||
|
Article: 'บทความ'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,11 @@ export default {
|
||||||
Translate: '翻译',
|
Translate: '翻译',
|
||||||
'Show original': '显示原文',
|
'Show original': '显示原文',
|
||||||
Website: '网站',
|
Website: '网站',
|
||||||
'Hide untrusted notes': '隐藏不受信任的笔记'
|
'Hide untrusted notes': '隐藏不受信任的笔记',
|
||||||
|
'Open in another client': '在其他客户端打开',
|
||||||
|
Community: '社区',
|
||||||
|
Group: '群组',
|
||||||
|
'Live event': '直播',
|
||||||
|
Article: '文章'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
112
src/lib/event.ts
112
src/lib/event.ts
|
|
@ -67,15 +67,6 @@ export function isProtectedEvent(event: Event) {
|
||||||
return event.tags.some(([tagName]) => tagName === '-')
|
return event.tags.some(([tagName]) => tagName === '-')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSupportedKind(kind: number) {
|
|
||||||
return [
|
|
||||||
kinds.ShortTextNote,
|
|
||||||
kinds.Highlights,
|
|
||||||
ExtendedKind.PICTURE,
|
|
||||||
ExtendedKind.COMMENT
|
|
||||||
].includes(kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getParentEventTag(event?: Event) {
|
export function getParentEventTag(event?: Event) {
|
||||||
if (!event || ![kinds.ShortTextNote, ExtendedKind.COMMENT].includes(event.kind)) return undefined
|
if (!event || ![kinds.ShortTextNote, ExtendedKind.COMMENT].includes(event.kind)) return undefined
|
||||||
|
|
||||||
|
|
@ -609,3 +600,106 @@ export function createFakeEvent(event: Partial<Event>): Event {
|
||||||
...event
|
...event
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLongFormArticleMetadata(event: Event) {
|
||||||
|
let title: string | undefined
|
||||||
|
let summary: string | undefined
|
||||||
|
let image: 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 === 't' && tagValue && tags.size < 6) {
|
||||||
|
tags.add(tagValue.toLocaleLowerCase())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
title = event.tags.find(tagNameEquals('d'))?.[1] ?? 'no title'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, summary, image, tags: Array.from(tags) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLiveEventMetadata(event: Event) {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroupMetadata(event: Event) {
|
||||||
|
let d: string | undefined
|
||||||
|
let name: string | undefined
|
||||||
|
let about: string | undefined
|
||||||
|
let picture: string | undefined
|
||||||
|
const tags = new Set<string>()
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCommunityDefinition(event: Event) {
|
||||||
|
let name: string | undefined
|
||||||
|
let description: string | undefined
|
||||||
|
let image: string | undefined
|
||||||
|
|
||||||
|
event.tags.forEach(([tagName, tagValue]) => {
|
||||||
|
if (tagName === 'name') {
|
||||||
|
name = tagValue
|
||||||
|
} else if (tagName === 'description') {
|
||||||
|
description = tagValue
|
||||||
|
} else if (tagName === 'image') {
|
||||||
|
image = tagValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
name = event.tags.find(tagNameEquals('d'))?.[1] ?? 'no name'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, description, image }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,5 +65,7 @@ export const toHablaLongFormArticle = (event: Event) => {
|
||||||
export const toZapStreamLiveEvent = (event: Event) => {
|
export const toZapStreamLiveEvent = (event: Event) => {
|
||||||
return `https://zap.stream/${getSharableEventId(event)}`
|
return `https://zap.stream/${getSharableEventId(event)}`
|
||||||
}
|
}
|
||||||
export const toChachiChat = (relay: string, d: string) => `https://chachi.chat/${relay}/${d}`
|
export const toChachiChat = (relay: string, d: string) => {
|
||||||
|
return `https://chachi.chat/${relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')}/${d}`
|
||||||
|
}
|
||||||
export const toNjump = (id: string) => `https://njump.me/${id}`
|
export const toNjump = (id: string) => `https://njump.me/${id}`
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { ExtendedKind } from '@/constants'
|
import { ExtendedKind } from '@/constants'
|
||||||
import { useFetchEvent } from '@/hooks'
|
import { useFetchEvent } from '@/hooks'
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { getParentEventId, getRootEventId, isPictureEvent, isSupportedKind } from '@/lib/event'
|
import { getParentEventId, getRootEventId, isPictureEvent } from '@/lib/event'
|
||||||
import { toNote, toNoteList } from '@/lib/link'
|
import { toNote, toNoteList } from '@/lib/link'
|
||||||
import { tagNameEquals } from '@/lib/tag'
|
import { tagNameEquals } from '@/lib/tag'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { cn } from '@/lib/utils'
|
||||||
import { forwardRef, useMemo } from 'react'
|
import { forwardRef, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import NotFoundPage from '../NotFoundPage'
|
import NotFoundPage from '../NotFoundPage'
|
||||||
|
|
@ -81,6 +81,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
|
||||||
event={event}
|
event={event}
|
||||||
className="select-text"
|
className="select-text"
|
||||||
hideParentNotePreview
|
hideParentNotePreview
|
||||||
|
originalNoteId={id}
|
||||||
/>
|
/>
|
||||||
<NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes />
|
<NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -109,19 +110,14 @@ function ExternalRoot({ value }: { value: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ParentNote({ eventId }: { eventId?: string }) {
|
function ParentNote({ eventId }: { eventId?: string }) {
|
||||||
const { t } = useTranslation()
|
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const { mutePubkeys } = useMuteList()
|
|
||||||
const { event, isFetching } = useFetchEvent(eventId)
|
const { event, isFetching } = useFetchEvent(eventId)
|
||||||
if (!eventId) return null
|
if (!eventId) return null
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card
|
<Card className="flex space-x-1 p-1 items-center clickable text-sm text-muted-foreground">
|
||||||
className="flex space-x-1 p-1 items-center clickable text-sm text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => push(toNote(eventId))}
|
|
||||||
>
|
|
||||||
<Skeleton className="shrink w-4 h-4 rounded-full" />
|
<Skeleton className="shrink w-4 h-4 rounded-full" />
|
||||||
<div className="py-1 flex-1">
|
<div className="py-1 flex-1">
|
||||||
<Skeleton className="h-3" />
|
<Skeleton className="h-3" />
|
||||||
|
|
@ -132,54 +128,19 @@ function ParentNote({ eventId }: { eventId?: string }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Card className="flex p-1 items-center justify-center text-sm text-muted-foreground">
|
|
||||||
[{t('Note not found')}]
|
|
||||||
</Card>
|
|
||||||
<div className="ml-5 w-px h-2 bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mutePubkeys.includes(event.pubkey)) {
|
|
||||||
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" />
|
|
||||||
<div className="shrink-0">[{t('This user has been muted')}]</div>
|
|
||||||
</Card>
|
|
||||||
<div className="ml-5 w-px h-2 bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSupportedKind(event.kind)) {
|
|
||||||
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" />
|
|
||||||
<div className="shrink-0">[{t('Cannot handle event of kind k', { k: event.kind })}]</div>
|
|
||||||
</Card>
|
|
||||||
<div className="ml-5 w-px h-2 bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card
|
<Card
|
||||||
className="flex space-x-1 p-1 items-center clickable text-sm text-muted-foreground hover:text-foreground"
|
className={cn(
|
||||||
onClick={() => push(toNote(eventId))}
|
'flex space-x-1 p-1 items-center clickable text-sm text-muted-foreground',
|
||||||
|
event && 'hover:text-foreground'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!event) return
|
||||||
|
push(toNote(eventId))
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
|
{event && <UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />}
|
||||||
<ContentPreview className="truncate" event={event} />
|
<ContentPreview className="truncate" event={event} />
|
||||||
</Card>
|
</Card>
|
||||||
<div className="ml-5 w-px h-2 bg-border" />
|
<div className="ml-5 w-px h-2 bg-border" />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue