refactor: 💨
This commit is contained in:
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 {
|
||||
EmbeddedEmojiParser,
|
||||
EmbeddedEventParser,
|
||||
EmbeddedImageParser,
|
||||
EmbeddedMentionParser,
|
||||
EmbeddedVideoParser,
|
||||
parseContent
|
||||
} from '@/lib/content-parser'
|
||||
import { extractEmojiInfosFromTags } from '@/lib/event'
|
||||
import { ExtendedKind } from '@/constants'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EmbeddedMentionText } from '../Embedded'
|
||||
import Emoji from '../Emoji'
|
||||
import CommunityDefinitionPreview from './CommunityDefinitionPreview'
|
||||
import GroupMetadataPreview from './GroupMetadataPreview'
|
||||
import LiveEventPreview from './LiveEventPreview'
|
||||
import LongFormArticlePreview from './LongFormArticlePreview'
|
||||
import NormalContentPreview from './NormalContentPreview'
|
||||
|
||||
export default function ContentPreview({
|
||||
event,
|
||||
@@ -25,56 +20,49 @@ export default function ContentPreview({
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const translatedEvent = useTranslatedEvent(event?.id)
|
||||
const nodes = useMemo(() => {
|
||||
if (!event) return [{ type: 'text', data: `[${t('Note not found')}]` }]
|
||||
const { mutePubkeys } = useMuteList()
|
||||
const isMuted = useMemo(
|
||||
() => (event ? mutePubkeys.includes(event.pubkey) : false),
|
||||
[mutePubkeys, event]
|
||||
)
|
||||
|
||||
if (event.kind === kinds.Highlights) return []
|
||||
if (!event) {
|
||||
return <div className={cn('pointer-events-none', className)}>{`[${t('Note not found')}]`}</div>
|
||||
}
|
||||
|
||||
return parseContent(translatedEvent?.content ?? event.content, [
|
||||
EmbeddedImageParser,
|
||||
EmbeddedVideoParser,
|
||||
EmbeddedEventParser,
|
||||
EmbeddedMentionParser,
|
||||
EmbeddedEmojiParser
|
||||
])
|
||||
}, [event, translatedEvent])
|
||||
|
||||
if (event?.kind === kinds.Highlights) {
|
||||
if (isMuted) {
|
||||
return (
|
||||
<div className={cn('pointer-events-none italic', className)} onClick={onClick}>
|
||||
{event.content}
|
||||
<div className={cn('pointer-events-none', className)}>[{t('This user has been muted')}]</div>
|
||||
)
|
||||
}
|
||||
|
||||
if ([kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.PICTURE].includes(event.kind)) {
|
||||
return <NormalContentPreview event={event} className={className} onClick={onClick} />
|
||||
}
|
||||
|
||||
if (event.kind === kinds.Highlights) {
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>
|
||||
[{t('Highlight')}] <span className="italic">{event.content}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const emojiInfos = extractEmojiInfosFromTags(event?.tags)
|
||||
if (event.kind === kinds.LongFormArticle) {
|
||||
return <LongFormArticlePreview event={event} className={className} onClick={onClick} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)} onClick={onClick}>
|
||||
{nodes.map((node, index) => {
|
||||
if (node.type === 'text') {
|
||||
return node.data
|
||||
}
|
||||
if (node.type === 'image' || node.type === 'images') {
|
||||
return index > 0 ? ` [${t('image')}]` : `[${t('image')}]`
|
||||
}
|
||||
if (node.type === 'video') {
|
||||
return index > 0 ? ` [${t('video')}]` : `[${t('video')}]`
|
||||
}
|
||||
if (node.type === 'event') {
|
||||
return index > 0 ? ` [${t('note')}]` : `[${t('note')}]`
|
||||
}
|
||||
if (node.type === 'mention') {
|
||||
return <EmbeddedMentionText key={index} userId={node.data.split(':')[1]} />
|
||||
}
|
||||
if (node.type === 'emoji') {
|
||||
const shortcode = node.data.split(':')[1]
|
||||
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
|
||||
if (!emoji) return node.data
|
||||
return <Emoji key={index} emoji={emoji} />
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
if (event.kind === ExtendedKind.GROUP_METADATA) {
|
||||
return <GroupMetadataPreview event={event} className={className} onClick={onClick} />
|
||||
}
|
||||
|
||||
if (event.kind === kinds.CommunityDefinition) {
|
||||
return <CommunityDefinitionPreview event={event} className={className} onClick={onClick} />
|
||||
}
|
||||
|
||||
if (event.kind === kinds.LiveEvent) {
|
||||
return <LiveEventPreview event={event} className={className} onClick={onClick} />
|
||||
}
|
||||
|
||||
return <div className={className}>[{t('Cannot handle event of kind k', { k: event.kind })}]</div>
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import GenericNoteCard from '../NoteCard/GenericNoteCard'
|
||||
import ClientSelect from '../ClientSelect'
|
||||
import MainNoteCard from '../NoteCard/MainNoteCard'
|
||||
|
||||
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
|
||||
const { event, isFetching } = useFetchEvent(noteId)
|
||||
@@ -19,7 +17,7 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className?
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericNoteCard
|
||||
<MainNoteCard
|
||||
className={cn('w-full', className)}
|
||||
event={event}
|
||||
embedded
|
||||
@@ -46,23 +44,12 @@ function EmbeddedNoteSkeleton({ className }: { className?: string }) {
|
||||
|
||||
function EmbeddedNoteNotFound({ noteId, className }: { noteId: string; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={cn('text-left p-2 sm:p-3 border rounded-lg', className)}>
|
||||
<div className="flex flex-col items-center text-muted-foreground font-medium gap-2">
|
||||
<div>{t('Sorry! The note cannot be found 😔')}</div>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(noteId)
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{isCopied ? <Check /> : <Copy />} {t('Copy event ID')}
|
||||
</Button>
|
||||
<ClientSelect variant="secondary" className="w-full mt-2" originalNoteId={noteId} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
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 { createFakeEvent, isSupportedKind } from '@/lib/event'
|
||||
import { createFakeEvent } from '@/lib/event'
|
||||
import { toNjump, toNote } from '@/lib/link'
|
||||
import { isValidPubkey } from '@/lib/pubkey'
|
||||
import { generateEventIdFromATag } from '@/lib/tag'
|
||||
@@ -110,7 +110,7 @@ function HighlightSource({ event }: { event: Event }) {
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<div className="shrink-0">{t('From')}</div>
|
||||
{pubkey && <UserAvatar userId={pubkey} size="xSmall" className="cursor-pointer" />}
|
||||
{referenceEvent && isSupportedKind(referenceEvent.kind) ? (
|
||||
{referenceEvent ? (
|
||||
<ContentPreview
|
||||
className="truncate underline pointer-events-auto cursor-pointer hover:text-foreground"
|
||||
event={referenceEvent}
|
||||
|
||||
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 { ExternalLink } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ClientSelect from '../ClientSelect'
|
||||
|
||||
export function UnknownNote({ event, className }: { event: Event; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
@@ -17,16 +14,7 @@ export function UnknownNote({ event, className }: { event: Event; className?: st
|
||||
)}
|
||||
>
|
||||
<div>{t('Cannot handle event of kind k', { k: event.kind })}</div>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.open(toNjump(getSharableEventId(event)), '_blank')
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<ExternalLink />
|
||||
<div>{t('View on njump.me')}</div>
|
||||
</Button>
|
||||
<ClientSelect event={event} variant="secondary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { ExtendedKind } from '@/constants'
|
||||
import {
|
||||
extractImageInfosFromEventTags,
|
||||
getParentEventId,
|
||||
getUsingClient,
|
||||
isNsfwEvent,
|
||||
isPictureEvent,
|
||||
isSupportedKind
|
||||
isPictureEvent
|
||||
} from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
@@ -20,18 +21,25 @@ import ParentNotePreview from '../ParentNotePreview'
|
||||
import TranslateButton from '../TranslateButton'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import CommunityDefinition from './CommunityDefinition'
|
||||
import GroupMetadata from './GroupMetadata'
|
||||
import Highlight from './Highlight'
|
||||
import IValue from './IValue'
|
||||
import LiveEvent from './LiveEvent'
|
||||
import LongFormArticle from './LongFormArticle'
|
||||
import MutedNote from './MutedNote'
|
||||
import NsfwNote from './NsfwNote'
|
||||
import { UnknownNote } from './UnknownNote'
|
||||
|
||||
export default function Note({
|
||||
event,
|
||||
originalNoteId,
|
||||
size = 'normal',
|
||||
className,
|
||||
hideParentNotePreview = false
|
||||
}: {
|
||||
event: Event
|
||||
originalNoteId?: string
|
||||
size?: 'normal' | 'small'
|
||||
className?: string
|
||||
hideParentNotePreview?: boolean
|
||||
@@ -48,14 +56,37 @@ export default function Note({
|
||||
)
|
||||
const usingClient = useMemo(() => getUsingClient(event), [event])
|
||||
const [showNsfw, setShowNsfw] = useState(false)
|
||||
const { mutePubkeys } = useMuteList()
|
||||
const [showMuted, setShowMuted] = useState(false)
|
||||
|
||||
let content: React.ReactNode
|
||||
if (!isSupportedKind(event.kind)) {
|
||||
if (
|
||||
![
|
||||
kinds.ShortTextNote,
|
||||
kinds.Highlights,
|
||||
kinds.LongFormArticle,
|
||||
kinds.LiveEvent,
|
||||
kinds.CommunityDefinition,
|
||||
ExtendedKind.GROUP_METADATA,
|
||||
ExtendedKind.PICTURE,
|
||||
ExtendedKind.COMMENT
|
||||
].includes(event.kind)
|
||||
) {
|
||||
content = <UnknownNote className="mt-2" event={event} />
|
||||
} else if (mutePubkeys.includes(event.pubkey) && !showMuted) {
|
||||
content = <MutedNote show={() => setShowMuted(true)} />
|
||||
} else if (isNsfwEvent(event) && !showNsfw) {
|
||||
content = <NsfwNote show={() => setShowNsfw(true)} />
|
||||
} else if (event.kind === kinds.Highlights) {
|
||||
content = <Highlight className="mt-2" event={event} />
|
||||
} else if (event.kind === kinds.LongFormArticle) {
|
||||
content = <LongFormArticle className="mt-2" event={event} />
|
||||
} else if (event.kind === kinds.LiveEvent) {
|
||||
content = <LiveEvent className="mt-2" event={event} />
|
||||
} else if (event.kind === ExtendedKind.GROUP_METADATA) {
|
||||
content = <GroupMetadata className="mt-2" event={event} originalNoteId={originalNoteId} />
|
||||
} else if (event.kind === kinds.CommunityDefinition) {
|
||||
content = <CommunityDefinition className="mt-2" event={event} />
|
||||
} else {
|
||||
content = <Content className="mt-2" event={event} />
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
className,
|
||||
reposter,
|
||||
embedded
|
||||
embedded,
|
||||
originalNoteId
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
reposter?: string
|
||||
embedded?: boolean
|
||||
originalNoteId?: string
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
|
||||
@@ -25,7 +27,7 @@ export default function MainNoteCard({
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toNote(event))
|
||||
push(toNote(originalNoteId ?? event))
|
||||
}}
|
||||
>
|
||||
<div className={`clickable ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'py-3'}`}>
|
||||
@@ -35,6 +37,7 @@ export default function MainNoteCard({
|
||||
className={embedded ? '' : 'px-4'}
|
||||
size={embedded ? 'small' : 'normal'}
|
||||
event={event}
|
||||
originalNoteId={originalNoteId}
|
||||
/>
|
||||
</Collapsible>
|
||||
{!embedded && <NoteStats className="mt-3 px-4" event={event} />}
|
||||
|
||||
@@ -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 { Event, kinds, nip19, verifyEvent } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import GenericNoteCard from './GenericNoteCard'
|
||||
import MainNoteCard from './MainNoteCard'
|
||||
|
||||
export default function RepostNoteCard({
|
||||
event,
|
||||
@@ -61,5 +61,5 @@ export default function RepostNoteCard({
|
||||
return null
|
||||
}
|
||||
|
||||
return <GenericNoteCard className={className} reposter={event.pubkey} event={targetEvent} />
|
||||
return <MainNoteCard className={className} reposter={event.pubkey} event={targetEvent} />
|
||||
}
|
||||
|
||||
@@ -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 { Event, kinds } from 'nostr-tools'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import GenericNoteCard from './GenericNoteCard'
|
||||
import MainNoteCard from './MainNoteCard'
|
||||
import RepostNoteCard from './RepostNoteCard'
|
||||
|
||||
export default function NoteCard({
|
||||
@@ -24,7 +24,7 @@ export default function NoteCard({
|
||||
<RepostNoteCard event={event} className={className} filterMutedNotes={filterMutedNotes} />
|
||||
)
|
||||
}
|
||||
return <GenericNoteCard event={event} className={className} />
|
||||
return <MainNoteCard event={event} className={className} />
|
||||
}
|
||||
|
||||
export function NoteCardLoadingSkeleton({ isPictures }: { isPictures: boolean }) {
|
||||
|
||||
@@ -115,7 +115,13 @@ export default function NoteList({
|
||||
subRequests.push({
|
||||
urls: myRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5),
|
||||
filter: {
|
||||
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Highlights, ExtendedKind.COMMENT],
|
||||
kinds: [
|
||||
kinds.ShortTextNote,
|
||||
kinds.Repost,
|
||||
kinds.Highlights,
|
||||
ExtendedKind.COMMENT,
|
||||
kinds.LongFormArticle
|
||||
],
|
||||
authors: [pubkey],
|
||||
'#p': [author],
|
||||
limit: LIMIT
|
||||
@@ -124,7 +130,13 @@ export default function NoteList({
|
||||
subRequests.push({
|
||||
urls: targetRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5),
|
||||
filter: {
|
||||
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Highlights, ExtendedKind.COMMENT],
|
||||
kinds: [
|
||||
kinds.ShortTextNote,
|
||||
kinds.Repost,
|
||||
kinds.Highlights,
|
||||
ExtendedKind.COMMENT,
|
||||
kinds.LongFormArticle
|
||||
],
|
||||
authors: [author],
|
||||
'#p': [pubkey],
|
||||
limit: LIMIT
|
||||
@@ -140,7 +152,13 @@ export default function NoteList({
|
||||
kinds:
|
||||
filterType === 'pictures'
|
||||
? [ExtendedKind.PICTURE]
|
||||
: [kinds.ShortTextNote, kinds.Repost, kinds.Highlights, ExtendedKind.COMMENT],
|
||||
: [
|
||||
kinds.ShortTextNote,
|
||||
kinds.Repost,
|
||||
kinds.Highlights,
|
||||
ExtendedKind.COMMENT,
|
||||
kinds.LongFormArticle
|
||||
],
|
||||
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
|
||||
}
|
||||
if (relayUrls.length === 0 && (_filter.authors?.length || author)) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Image from '@/components/Image'
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { isSupportedKind } from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -54,7 +53,7 @@ export function ReactionNotification({
|
||||
return notification.content
|
||||
}, [notification])
|
||||
|
||||
if (!event || !eventId || !isSupportedKind(event.kind)) {
|
||||
if (!event || !eventId) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { isSupportedKind } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ContentPreview from '../ContentPreview'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
@@ -18,21 +15,15 @@ export default function ParentNotePreview({
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { mutePubkeys } = useMuteList()
|
||||
const { event, isFetching } = useFetchEvent(eventId)
|
||||
const isMuted = useMemo(
|
||||
() => (event ? mutePubkeys.includes(event.pubkey) : false),
|
||||
[mutePubkeys, event]
|
||||
)
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-44 max-w-full text-muted-foreground hover:text-foreground cursor-pointer',
|
||||
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-44 max-w-full text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="shrink-0">{t('reply to')}</div>
|
||||
<Skeleton className="w-4 h-4 rounded-full" />
|
||||
@@ -43,37 +34,18 @@ export default function ParentNotePreview({
|
||||
)
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0">{t('reply to')}</div>
|
||||
<div>{`[${t('Note not found')}]`}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground hover:text-foreground cursor-pointer',
|
||||
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground',
|
||||
event && 'hover:text-foreground cursor-pointer',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
onClick={event ? onClick : undefined}
|
||||
>
|
||||
<div className="shrink-0">{t('reply to')}</div>
|
||||
{event && <UserAvatar className="shrink-0" userId={event.pubkey} size="tiny" />}
|
||||
{isMuted ? (
|
||||
<div className="truncate">[{t('This user has been muted')}]</div>
|
||||
) : !isSupportedKind(event.kind) ? (
|
||||
<div className="truncate">[{t('Cannot handle event of kind k', { k: event.kind })}]</div>
|
||||
) : (
|
||||
<ContentPreview className="truncate" event={event} />
|
||||
)}
|
||||
<ContentPreview className="truncate" event={event} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ExtendedKind } from '@/constants'
|
||||
import { useTranslatedEvent } from '@/hooks'
|
||||
import { isSupportedKind } from '@/lib/event'
|
||||
import { toTranslation } from '@/lib/link'
|
||||
import { cn, detectLanguage } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
||||
import { Languages, Loader } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -22,7 +22,13 @@ export default function TranslateButton({
|
||||
const { translateEvent, showOriginalEvent } = useTranslationService()
|
||||
const [translating, setTranslating] = useState(false)
|
||||
const translatedEvent = useTranslatedEvent(event.id)
|
||||
const supported = useMemo(() => isSupportedKind(event.kind), [event])
|
||||
const supported = useMemo(
|
||||
() =>
|
||||
[kinds.ShortTextNote, kinds.Highlights, ExtendedKind.COMMENT, ExtendedKind.PICTURE].includes(
|
||||
event.kind
|
||||
),
|
||||
[event]
|
||||
)
|
||||
|
||||
const needTranslation = useMemo(() => {
|
||||
const detected = detectLanguage(event.content)
|
||||
|
||||
Reference in New Issue
Block a user