46 changed files with 1007 additions and 878 deletions
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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,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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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> |
|
||||||
) |
|
||||||
} |
|
||||||
Loading…
Reference in new issue