17 changed files with 717 additions and 64 deletions
@ -1,30 +1,69 @@ |
|||||||
import { PICTURE_EVENT_KIND } from '@/constants' |
import { Button } from '@/components/ui/button' |
||||||
|
import { Skeleton } from '@/components/ui/skeleton' |
||||||
import { useFetchEvent } from '@/hooks' |
import { useFetchEvent } from '@/hooks' |
||||||
import { toNoStrudelArticle, toNoStrudelNote, toNoStrudelStream } from '@/lib/link' |
|
||||||
import { cn } from '@/lib/utils' |
import { cn } from '@/lib/utils' |
||||||
import { kinds } from 'nostr-tools' |
import { Check, Copy } from 'lucide-react' |
||||||
import NormalNoteCard from '../NoteCard/NormalNoteCard' |
import { useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import GenericNoteCard from '../NoteCard/GenericNoteCard' |
||||||
|
|
||||||
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) { |
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) { |
||||||
const { event } = useFetchEvent(noteId) |
const { event, isFetching } = useFetchEvent(noteId) |
||||||
|
|
||||||
return event && [kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind) ? ( |
if (isFetching) { |
||||||
<NormalNoteCard className={cn('w-full', className)} event={event} embedded /> |
return <EmbeddedNoteSkeleton className={className} /> |
||||||
) : ( |
} |
||||||
<a |
|
||||||
href={ |
if (!event) { |
||||||
event?.kind === kinds.LongFormArticle |
return <EmbeddedNoteNotFound className={className} noteId={noteId} /> |
||||||
? toNoStrudelArticle(noteId) |
} |
||||||
: event?.kind === kinds.LiveEvent |
|
||||||
? toNoStrudelStream(noteId) |
return ( |
||||||
: toNoStrudelNote(noteId) |
<GenericNoteCard |
||||||
} |
className={cn('w-full', className)} |
||||||
target="_blank" |
event={event} |
||||||
className="text-highlight hover:underline" |
embedded |
||||||
|
originalNoteId={noteId} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function EmbeddedNoteSkeleton({ className }: { className?: string }) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cn('text-left p-2 sm:p-3 border rounded-lg', className)} |
||||||
onClick={(e) => e.stopPropagation()} |
onClick={(e) => e.stopPropagation()} |
||||||
rel="noreferrer" |
|
||||||
> |
> |
||||||
{noteId} |
<div className="flex items-center space-x-2"> |
||||||
</a> |
<Skeleton className="w-7 h-7 rounded-full" /> |
||||||
|
<Skeleton className="h-3 w-16 my-1" /> |
||||||
|
</div> |
||||||
|
<Skeleton className="w-full h-4 my-1 mt-2" /> |
||||||
|
<Skeleton className="w-2/3 h-4 my-1" /> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
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 />} Copy note ID |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,56 @@ |
|||||||
|
import { GROUP_METADATA_EVENT_KIND } from '@/constants' |
||||||
|
import { isSupportedKind } from '@/lib/event' |
||||||
|
import { Event, kinds } from 'nostr-tools' |
||||||
|
import GroupMetadataCard from './GroupMetadataCard' |
||||||
|
import LiveEventCard from './LiveEventCard' |
||||||
|
import LongFormArticleCard from './LongFormArticleCard' |
||||||
|
import MainNoteCard from './MainNoteCard' |
||||||
|
import UnknownNoteCard from './UnknownNoteCard' |
||||||
|
|
||||||
|
export default function GenericNoteCard({ |
||||||
|
event, |
||||||
|
className, |
||||||
|
reposter, |
||||||
|
embedded, |
||||||
|
originalNoteId |
||||||
|
}: { |
||||||
|
event: Event |
||||||
|
className?: string |
||||||
|
reposter?: string |
||||||
|
embedded?: boolean |
||||||
|
originalNoteId?: string |
||||||
|
}) { |
||||||
|
if (isSupportedKind(event.kind)) { |
||||||
|
return ( |
||||||
|
<MainNoteCard event={event} className={className} reposter={reposter} embedded={embedded} /> |
||||||
|
) |
||||||
|
} |
||||||
|
if (event.kind === kinds.LongFormArticle) { |
||||||
|
return ( |
||||||
|
<LongFormArticleCard |
||||||
|
className={className} |
||||||
|
reposter={reposter} |
||||||
|
event={event} |
||||||
|
embedded={embedded} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
if (event.kind === kinds.LiveEvent) { |
||||||
|
return ( |
||||||
|
<LiveEventCard event={event} className={className} reposter={reposter} embedded={embedded} /> |
||||||
|
) |
||||||
|
} |
||||||
|
if (event.kind === GROUP_METADATA_EVENT_KIND) { |
||||||
|
return ( |
||||||
|
<GroupMetadataCard |
||||||
|
className={className} |
||||||
|
event={event} |
||||||
|
originalNoteId={originalNoteId} |
||||||
|
embedded={embedded} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
return ( |
||||||
|
<UnknownNoteCard event={event} className={className} reposter={reposter} embedded={embedded} /> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,147 @@ |
|||||||
|
import { Badge } from '@/components/ui/badge' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { Separator } from '@/components/ui/separator' |
||||||
|
import { getSharableEventId } from '@/lib/event' |
||||||
|
import { toChachiChat } from '@/lib/link' |
||||||
|
import { simplifyUrl } from '@/lib/url' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { Check, Copy, ExternalLink } from 'lucide-react' |
||||||
|
import { Event, nip19 } from 'nostr-tools' |
||||||
|
import { useMemo, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { FormattedTimestamp } from '../FormattedTimestamp' |
||||||
|
import Image from '../Image' |
||||||
|
import UserAvatar from '../UserAvatar' |
||||||
|
import Username from '../Username' |
||||||
|
import RepostDescription from './RepostDescription' |
||||||
|
|
||||||
|
export default function GroupMetadataCard({ |
||||||
|
event, |
||||||
|
className, |
||||||
|
originalNoteId, |
||||||
|
embedded = false, |
||||||
|
reposter |
||||||
|
}: { |
||||||
|
event: Event |
||||||
|
className?: string |
||||||
|
originalNoteId?: string |
||||||
|
embedded?: boolean |
||||||
|
reposter?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { isSmallScreen } = useScreenSize() |
||||||
|
const [isCopied, setIsCopied] = useState(false) |
||||||
|
const metadata = useMemo(() => { |
||||||
|
let d: string | undefined |
||||||
|
let name: string | undefined |
||||||
|
let about: string | undefined |
||||||
|
let picture: string | undefined |
||||||
|
let relay: string | undefined |
||||||
|
const tags = new Set<string>() |
||||||
|
|
||||||
|
if (originalNoteId) { |
||||||
|
const pointer = nip19.decode(originalNoteId) |
||||||
|
if (pointer.type === 'naddr' && pointer.data.relays?.length) { |
||||||
|
relay = pointer.data.relays[0] |
||||||
|
} |
||||||
|
} |
||||||
|
if (!relay) { |
||||||
|
relay = client.getEventHint(event.id) |
||||||
|
} |
||||||
|
|
||||||
|
event.tags.forEach(([tagName, tagValue]) => { |
||||||
|
if (tagName === 'name') { |
||||||
|
name = tagValue |
||||||
|
} else if (tagName === 'about') { |
||||||
|
about = tagValue |
||||||
|
} else if (tagName === 'picture') { |
||||||
|
picture = tagValue |
||||||
|
} else if (tagName === 't' && tagValue) { |
||||||
|
tags.add(tagValue.toLocaleLowerCase()) |
||||||
|
} else if (tagName === 'd') { |
||||||
|
d = tagValue |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
if (!name) { |
||||||
|
name = d ?? 'no name' |
||||||
|
} |
||||||
|
|
||||||
|
return { d, name, about, picture, tags: Array.from(tags), relay } |
||||||
|
}, [event, originalNoteId]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={cn('relative', className)}> |
||||||
|
<div className={cn(embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3')}> |
||||||
|
<RepostDescription reposter={reposter} /> |
||||||
|
<div className="flex items-center space-x-2"> |
||||||
|
<UserAvatar userId={event.pubkey} size={embedded ? 'small' : 'normal'} /> |
||||||
|
<div |
||||||
|
className={`flex-1 w-0 ${embedded ? 'flex space-x-2 items-center overflow-hidden' : ''}`} |
||||||
|
> |
||||||
|
<Username |
||||||
|
userId={event.pubkey} |
||||||
|
className={cn('font-semibold flex truncate', embedded ? 'text-sm' : '')} |
||||||
|
skeletonClassName={embedded ? 'h-3' : 'h-4'} |
||||||
|
/> |
||||||
|
<div className="text-xs text-muted-foreground line-clamp-1"> |
||||||
|
<FormattedTimestamp timestamp={event.created_at} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="flex gap-2 items-start mt-2"> |
||||||
|
{metadata.picture && ( |
||||||
|
<Image image={{ url: metadata.picture }} className="h-32 aspect-square rounded-lg" /> |
||||||
|
)} |
||||||
|
<div className="flex-1 w-0 space-y-1"> |
||||||
|
<div className="text-xl font-semibold line-clamp-1">{metadata.name}</div> |
||||||
|
{metadata.about && ( |
||||||
|
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.about}</div> |
||||||
|
)} |
||||||
|
{metadata.tags.length > 0 && ( |
||||||
|
<div className="mt-2 flex gap-1 flex-wrap"> |
||||||
|
{metadata.tags.map((tag) => ( |
||||||
|
<Badge key={tag} variant="secondary"> |
||||||
|
{tag} |
||||||
|
</Badge> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
{(!metadata.relay || !metadata.d) && ( |
||||||
|
<Button |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
navigator.clipboard.writeText(originalNoteId ?? getSharableEventId(event)) |
||||||
|
setIsCopied(true) |
||||||
|
setTimeout(() => setIsCopied(false), 2000) |
||||||
|
}} |
||||||
|
variant="ghost" |
||||||
|
> |
||||||
|
{isCopied ? <Check /> : <Copy />} Copy group ID |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{!embedded && <Separator />} |
||||||
|
{!isSmallScreen && metadata.relay && metadata.d && ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'absolute top-0 w-full h-full bg-muted/80 backdrop-blur-sm flex flex-col items-center justify-center cursor-pointer transition-opacity opacity-0 hover:opacity-100', |
||||||
|
embedded ? 'rounded-lg' : '' |
||||||
|
)} |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
window.open(toChachiChat(simplifyUrl(metadata.relay), metadata.d!), '_blank') |
||||||
|
}} |
||||||
|
> |
||||||
|
<div className="flex gap-2 items-center font-semibold"> |
||||||
|
<ExternalLink className="size-4" /> {t('Open in a', { a: 'Chachi' })} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,168 @@ |
|||||||
|
import { Badge } from '@/components/ui/badge' |
||||||
|
import { Separator } from '@/components/ui/separator' |
||||||
|
import { toZapStreamLiveEvent } from '@/lib/link' |
||||||
|
import { tagNameEquals } from '@/lib/tag' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||||
|
import { ExternalLink } from 'lucide-react' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useMemo } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { FormattedTimestamp } from '../FormattedTimestamp' |
||||||
|
import Image from '../Image' |
||||||
|
import UserAvatar from '../UserAvatar' |
||||||
|
import Username from '../Username' |
||||||
|
import RepostDescription from './RepostDescription' |
||||||
|
|
||||||
|
export default function LiveEventCard({ |
||||||
|
event, |
||||||
|
className, |
||||||
|
embedded = false, |
||||||
|
reposter |
||||||
|
}: { |
||||||
|
event: Event |
||||||
|
className?: string |
||||||
|
embedded?: boolean |
||||||
|
reposter?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { isSmallScreen } = useScreenSize() |
||||||
|
const metadata = useMemo(() => { |
||||||
|
let title: string | undefined |
||||||
|
let summary: string | undefined |
||||||
|
let image: string | undefined |
||||||
|
let status: string | undefined |
||||||
|
const tags = new Set<string>() |
||||||
|
|
||||||
|
event.tags.forEach(([tagName, tagValue]) => { |
||||||
|
if (tagName === 'title') { |
||||||
|
title = tagValue |
||||||
|
} else if (tagName === 'summary') { |
||||||
|
summary = tagValue |
||||||
|
} else if (tagName === 'image') { |
||||||
|
image = tagValue |
||||||
|
} else if (tagName === 'status') { |
||||||
|
status = tagValue |
||||||
|
} else if (tagName === 't' && tagValue && tags.size < 6) { |
||||||
|
tags.add(tagValue.toLocaleLowerCase()) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
if (!title) { |
||||||
|
title = event.tags.find(tagNameEquals('d'))?.[1] ?? 'no title' |
||||||
|
} |
||||||
|
|
||||||
|
return { title, summary, image, status, tags: Array.from(tags) } |
||||||
|
}, [event]) |
||||||
|
|
||||||
|
const liveStatusComponent = |
||||||
|
metadata.status && |
||||||
|
(metadata.status === 'live' ? ( |
||||||
|
<Badge className="bg-green-400 hover:bg-green-400">live</Badge> |
||||||
|
) : metadata.status === 'ended' ? ( |
||||||
|
<Badge variant="destructive">ended</Badge> |
||||||
|
) : ( |
||||||
|
<Badge variant="secondary">{metadata.status}</Badge> |
||||||
|
)) |
||||||
|
|
||||||
|
const userInfoComponent = ( |
||||||
|
<div className="flex items-center space-x-2"> |
||||||
|
<UserAvatar userId={event.pubkey} size={embedded ? 'small' : 'normal'} /> |
||||||
|
<div |
||||||
|
className={`flex-1 w-0 ${embedded ? 'flex space-x-2 items-center overflow-hidden' : ''}`} |
||||||
|
> |
||||||
|
<div className="flex gap-2 items-center"> |
||||||
|
<Username |
||||||
|
userId={event.pubkey} |
||||||
|
className={cn('font-semibold flex truncate', embedded ? 'text-sm' : '')} |
||||||
|
skeletonClassName={embedded ? 'h-3' : 'h-4'} |
||||||
|
/> |
||||||
|
{liveStatusComponent} |
||||||
|
</div> |
||||||
|
<div className="text-xs text-muted-foreground line-clamp-1"> |
||||||
|
<FormattedTimestamp timestamp={event.created_at} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
|
||||||
|
const titleComponent = <div className="text-xl font-semibold line-clamp-1">{metadata.title}</div> |
||||||
|
|
||||||
|
const summaryComponent = metadata.summary && ( |
||||||
|
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.summary}</div> |
||||||
|
) |
||||||
|
|
||||||
|
const tagsComponent = metadata.tags.length > 0 && ( |
||||||
|
<div className="flex gap-1 flex-wrap"> |
||||||
|
{metadata.tags.map((tag) => ( |
||||||
|
<Badge key={tag} variant="secondary"> |
||||||
|
{tag} |
||||||
|
</Badge> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
) |
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => { |
||||||
|
e.stopPropagation() |
||||||
|
window.open(toZapStreamLiveEvent(event), '_blank') |
||||||
|
} |
||||||
|
|
||||||
|
if (isSmallScreen) { |
||||||
|
return ( |
||||||
|
<div className={className}> |
||||||
|
<div |
||||||
|
className={cn('flex flex-col gap-2', embedded ? 'p-2 border rounded-lg' : 'px-4 py-3')} |
||||||
|
onClick={handleClick} |
||||||
|
> |
||||||
|
<RepostDescription reposter={reposter} /> |
||||||
|
{userInfoComponent} |
||||||
|
{metadata.image && ( |
||||||
|
<Image |
||||||
|
image={{ url: metadata.image }} |
||||||
|
className="w-full aspect-video object-cover rounded-lg" |
||||||
|
/> |
||||||
|
)} |
||||||
|
<div className="space-y-1"> |
||||||
|
{titleComponent} |
||||||
|
{summaryComponent} |
||||||
|
{tagsComponent} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{!embedded && <Separator />} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={cn('relative', className)}> |
||||||
|
<div |
||||||
|
className={cn('flex gap-2 items-center', embedded ? 'p-3 border rounded-lg' : 'px-4 py-3')} |
||||||
|
> |
||||||
|
<div className="flex-1 w-0"> |
||||||
|
<RepostDescription reposter={reposter} /> |
||||||
|
{userInfoComponent} |
||||||
|
<div className="mt-2 space-y-1"> |
||||||
|
{titleComponent} |
||||||
|
{summaryComponent} |
||||||
|
{tagsComponent} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{metadata.image && ( |
||||||
|
<Image image={{ url: metadata.image }} className="h-36 max-w-44 rounded-lg" /> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
{!embedded && <Separator />} |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'absolute top-0 w-full h-full bg-muted/80 backdrop-blur-sm flex flex-col items-center justify-center cursor-pointer transition-opacity opacity-0 hover:opacity-100', |
||||||
|
embedded ? 'rounded-lg' : '' |
||||||
|
)} |
||||||
|
onClick={handleClick} |
||||||
|
> |
||||||
|
<div className="flex gap-2 items-center font-semibold"> |
||||||
|
<ExternalLink className="size-4" /> {t('Open in a', { a: 'Zap Stream' })} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,161 @@ |
|||||||
|
import { Badge } from '@/components/ui/badge' |
||||||
|
import { Separator } from '@/components/ui/separator' |
||||||
|
import { toHablaLongFormArticle } from '@/lib/link' |
||||||
|
import { tagNameEquals } from '@/lib/tag' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||||
|
import { ExternalLink } from 'lucide-react' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useMemo } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import Image from '../Image' |
||||||
|
import UserAvatar from '../UserAvatar' |
||||||
|
import Username from '../Username' |
||||||
|
import RepostDescription from './RepostDescription' |
||||||
|
|
||||||
|
export default function LongFormArticleCard({ |
||||||
|
event, |
||||||
|
className, |
||||||
|
embedded = false, |
||||||
|
reposter |
||||||
|
}: { |
||||||
|
event: Event |
||||||
|
className?: string |
||||||
|
embedded?: boolean |
||||||
|
reposter?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { isSmallScreen } = useScreenSize() |
||||||
|
const metadata = useMemo(() => { |
||||||
|
let title: string | undefined |
||||||
|
let summary: string | undefined |
||||||
|
let image: string | undefined |
||||||
|
let publishDateString: string | undefined |
||||||
|
const tags = new Set<string>() |
||||||
|
|
||||||
|
event.tags.forEach(([tagName, tagValue]) => { |
||||||
|
if (tagName === 'title') { |
||||||
|
title = tagValue |
||||||
|
} else if (tagName === 'summary') { |
||||||
|
summary = tagValue |
||||||
|
} else if (tagName === 'image') { |
||||||
|
image = tagValue |
||||||
|
} else if (tagName === 'published_at') { |
||||||
|
try { |
||||||
|
const publishedAt = parseInt(tagValue) |
||||||
|
publishDateString = !isNaN(publishedAt) |
||||||
|
? new Date(publishedAt * 1000).toLocaleString() |
||||||
|
: undefined |
||||||
|
} catch { |
||||||
|
// ignore
|
||||||
|
} |
||||||
|
} else if (tagName === 't' && tagValue && tags.size < 6) { |
||||||
|
tags.add(tagValue.toLocaleLowerCase()) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
if (!title) { |
||||||
|
title = event.tags.find(tagNameEquals('d'))?.[1] ?? 'no title' |
||||||
|
} |
||||||
|
|
||||||
|
return { title, summary, image, publishDateString, tags: Array.from(tags) } |
||||||
|
}, [event]) |
||||||
|
|
||||||
|
const userInfoComponent = ( |
||||||
|
<div className="flex items-center space-x-2"> |
||||||
|
<UserAvatar userId={event.pubkey} size={embedded ? 'small' : 'normal'} /> |
||||||
|
<div |
||||||
|
className={`flex-1 w-0 ${embedded ? 'flex space-x-2 items-center overflow-hidden' : ''}`} |
||||||
|
> |
||||||
|
<Username |
||||||
|
userId={event.pubkey} |
||||||
|
className={cn('font-semibold flex truncate', embedded ? 'text-sm' : '')} |
||||||
|
skeletonClassName={embedded ? 'h-3' : 'h-4'} |
||||||
|
/> |
||||||
|
{metadata.publishDateString && ( |
||||||
|
<div className="text-xs text-muted-foreground mt-1">{metadata.publishDateString}</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
|
||||||
|
const titleComponent = <div className="text-xl font-semibold line-clamp-2">{metadata.title}</div> |
||||||
|
|
||||||
|
const tagsComponent = metadata.tags.length > 0 && ( |
||||||
|
<div className="flex gap-1 flex-wrap"> |
||||||
|
{metadata.tags.map((tag) => ( |
||||||
|
<Badge key={tag} variant="secondary"> |
||||||
|
{tag} |
||||||
|
</Badge> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
) |
||||||
|
|
||||||
|
const summaryComponent = metadata.summary && ( |
||||||
|
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.summary}</div> |
||||||
|
) |
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => { |
||||||
|
e.stopPropagation() |
||||||
|
window.open(toHablaLongFormArticle(event), '_blank') |
||||||
|
} |
||||||
|
|
||||||
|
if (isSmallScreen) { |
||||||
|
return ( |
||||||
|
<div className={className}> |
||||||
|
<div |
||||||
|
className={cn('flex flex-col gap-2', embedded ? 'p-2 border rounded-lg' : 'px-4 py-3')} |
||||||
|
onClick={handleClick} |
||||||
|
> |
||||||
|
<RepostDescription reposter={reposter} /> |
||||||
|
{userInfoComponent} |
||||||
|
{metadata.image && ( |
||||||
|
<Image |
||||||
|
image={{ url: metadata.image }} |
||||||
|
className="w-full aspect-video object-cover rounded-lg" |
||||||
|
/> |
||||||
|
)} |
||||||
|
<div className="space-y-1"> |
||||||
|
{titleComponent} |
||||||
|
{tagsComponent} |
||||||
|
{summaryComponent} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{!embedded && <Separator />} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={cn('relative', className)}> |
||||||
|
<div |
||||||
|
className={cn('flex gap-2 items-center', embedded ? 'p-3 border rounded-lg' : 'px-4 py-3')} |
||||||
|
> |
||||||
|
<div className="flex-1 w-0"> |
||||||
|
<RepostDescription reposter={reposter} /> |
||||||
|
{userInfoComponent} |
||||||
|
<div className="mt-2 space-y-1"> |
||||||
|
{titleComponent} |
||||||
|
{tagsComponent} |
||||||
|
{summaryComponent} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{metadata.image && ( |
||||||
|
<Image image={{ url: metadata.image }} className="h-36 max-w-48 rounded-lg" /> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
{!embedded && <Separator />} |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'absolute top-0 w-full h-full bg-muted/60 backdrop-blur-sm flex flex-col items-center justify-center cursor-pointer transition-opacity opacity-0 hover:opacity-100', |
||||||
|
embedded ? 'rounded-lg' : '' |
||||||
|
)} |
||||||
|
onClick={handleClick} |
||||||
|
> |
||||||
|
<div className="flex gap-2 items-center font-semibold"> |
||||||
|
<ExternalLink className="size-4" /> {t('Open in a', { a: 'Habla' })} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { Repeat2 } from 'lucide-react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import Username from '../Username' |
||||||
|
|
||||||
|
export default function RepostDescription({ |
||||||
|
reposter, |
||||||
|
className |
||||||
|
}: { |
||||||
|
reposter?: string | null |
||||||
|
className?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
if (!reposter) return null |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={cn('flex gap-1 text-sm items-center text-muted-foreground mb-1', className)}> |
||||||
|
<Repeat2 size={16} className="shrink-0" /> |
||||||
|
<Username userId={reposter} className="font-semibold truncate" skeletonClassName="h-3" /> |
||||||
|
<div>{t('reposted')}</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,65 @@ |
|||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { Separator } from '@/components/ui/separator' |
||||||
|
import { getSharableEventId } from '@/lib/event' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { Check, Copy } from 'lucide-react' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useState } from 'react' |
||||||
|
import RepostDescription from './RepostDescription' |
||||||
|
import UserAvatar from '../UserAvatar' |
||||||
|
import Username from '../Username' |
||||||
|
import { FormattedTimestamp } from '../FormattedTimestamp' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function UnknownNoteCard({ |
||||||
|
event, |
||||||
|
className, |
||||||
|
embedded = false, |
||||||
|
reposter |
||||||
|
}: { |
||||||
|
event: Event |
||||||
|
className?: string |
||||||
|
embedded?: boolean |
||||||
|
reposter?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const [isCopied, setIsCopied] = useState(false) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={className}> |
||||||
|
<div className={cn(embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3')}> |
||||||
|
<RepostDescription reposter={reposter} /> |
||||||
|
<div className="flex items-center space-x-2"> |
||||||
|
<UserAvatar userId={event.pubkey} size={embedded ? 'small' : 'normal'} /> |
||||||
|
<div |
||||||
|
className={`flex-1 w-0 ${embedded ? 'flex space-x-2 items-center overflow-hidden' : ''}`} |
||||||
|
> |
||||||
|
<Username |
||||||
|
userId={event.pubkey} |
||||||
|
className={cn('font-semibold flex truncate', embedded ? 'text-sm' : '')} |
||||||
|
skeletonClassName={embedded ? 'h-3' : 'h-4'} |
||||||
|
/> |
||||||
|
<div className="text-xs text-muted-foreground line-clamp-1"> |
||||||
|
<FormattedTimestamp timestamp={event.created_at} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="flex flex-col gap-2 items-center text-muted-foreground font-medium mt-2"> |
||||||
|
<div>{t('Cannot handle event of kind k', { k: event.kind })}</div> |
||||||
|
<Button |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
navigator.clipboard.writeText(getSharableEventId(event)) |
||||||
|
setIsCopied(true) |
||||||
|
setTimeout(() => setIsCopied(false), 2000) |
||||||
|
}} |
||||||
|
variant="ghost" |
||||||
|
> |
||||||
|
{isCopied ? <Check /> : <Copy />} Copy event ID |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{!embedded && <Separator />} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue