diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 6e4b530c..45e5394d 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -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 { toNoStrudelArticle, toNoStrudelNote, toNoStrudelStream } from '@/lib/link' import { cn } from '@/lib/utils' -import { kinds } from 'nostr-tools' -import NormalNoteCard from '../NoteCard/NormalNoteCard' +import { Check, Copy } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import GenericNoteCard from '../NoteCard/GenericNoteCard' 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) ? ( - - ) : ( - e.stopPropagation()} - rel="noreferrer" - > - {noteId} - + if (isFetching) { + return + } + + if (!event) { + return + } + + return ( + + ) +} + +function EmbeddedNoteSkeleton({ className }: { className?: string }) { + return ( +
e.stopPropagation()} + > +
+ + +
+ + +
+ ) +} + +function EmbeddedNoteNotFound({ noteId, className }: { noteId: string; className?: string }) { + const { t } = useTranslation() + const [isCopied, setIsCopied] = useState(false) + + return ( +
+
+
{t('Sorry! The note cannot be found 😔')}
+ +
+
) } diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 19d533be..5fb7eb82 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -27,7 +27,6 @@ export default function Note({ }) { const { push } = useSecondaryPage() const usingClient = useMemo(() => getUsingClient(event), [event]) - return (
diff --git a/src/components/NoteCard/GenericNoteCard.tsx b/src/components/NoteCard/GenericNoteCard.tsx new file mode 100644 index 00000000..55a3c022 --- /dev/null +++ b/src/components/NoteCard/GenericNoteCard.tsx @@ -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 ( + + ) + } + if (event.kind === kinds.LongFormArticle) { + return ( + + ) + } + if (event.kind === kinds.LiveEvent) { + return ( + + ) + } + if (event.kind === GROUP_METADATA_EVENT_KIND) { + return ( + + ) + } + return ( + + ) +} diff --git a/src/components/NoteCard/GroupMetadataCard.tsx b/src/components/NoteCard/GroupMetadataCard.tsx new file mode 100644 index 00000000..1d9eea07 --- /dev/null +++ b/src/components/NoteCard/GroupMetadataCard.tsx @@ -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() + + 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 ( +
+
+ +
+ +
+ +
+ +
+
+
+
+ {metadata.picture && ( + + )} +
+
{metadata.name}
+ {metadata.about && ( +
{metadata.about}
+ )} + {metadata.tags.length > 0 && ( +
+ {metadata.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + {(!metadata.relay || !metadata.d) && ( + + )} +
+
+
+ {!embedded && } + {!isSmallScreen && metadata.relay && metadata.d && ( +
{ + e.stopPropagation() + window.open(toChachiChat(simplifyUrl(metadata.relay), metadata.d!), '_blank') + }} + > +
+ {t('Open in a', { a: 'Chachi' })} +
+
+ )} +
+ ) +} diff --git a/src/components/NoteCard/LiveEventCard.tsx b/src/components/NoteCard/LiveEventCard.tsx new file mode 100644 index 00000000..db6e482a --- /dev/null +++ b/src/components/NoteCard/LiveEventCard.tsx @@ -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() + + 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' ? ( + live + ) : metadata.status === 'ended' ? ( + ended + ) : ( + {metadata.status} + )) + + const userInfoComponent = ( +
+ +
+
+ + {liveStatusComponent} +
+
+ +
+
+
+ ) + + const titleComponent =
{metadata.title}
+ + const summaryComponent = metadata.summary && ( +
{metadata.summary}
+ ) + + const tagsComponent = metadata.tags.length > 0 && ( +
+ {metadata.tags.map((tag) => ( + + {tag} + + ))} +
+ ) + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + window.open(toZapStreamLiveEvent(event), '_blank') + } + + if (isSmallScreen) { + return ( +
+
+ + {userInfoComponent} + {metadata.image && ( + + )} +
+ {titleComponent} + {summaryComponent} + {tagsComponent} +
+
+ {!embedded && } +
+ ) + } + + return ( +
+
+
+ + {userInfoComponent} +
+ {titleComponent} + {summaryComponent} + {tagsComponent} +
+
+ {metadata.image && ( + + )} +
+ {!embedded && } +
+
+ {t('Open in a', { a: 'Zap Stream' })} +
+
+
+ ) +} diff --git a/src/components/NoteCard/LongFormArticleCard.tsx b/src/components/NoteCard/LongFormArticleCard.tsx new file mode 100644 index 00000000..15ce2745 --- /dev/null +++ b/src/components/NoteCard/LongFormArticleCard.tsx @@ -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() + + 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 = ( +
+ +
+ + {metadata.publishDateString && ( +
{metadata.publishDateString}
+ )} +
+
+ ) + + const titleComponent =
{metadata.title}
+ + const tagsComponent = metadata.tags.length > 0 && ( +
+ {metadata.tags.map((tag) => ( + + {tag} + + ))} +
+ ) + + const summaryComponent = metadata.summary && ( +
{metadata.summary}
+ ) + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + window.open(toHablaLongFormArticle(event), '_blank') + } + + if (isSmallScreen) { + return ( +
+
+ + {userInfoComponent} + {metadata.image && ( + + )} +
+ {titleComponent} + {tagsComponent} + {summaryComponent} +
+
+ {!embedded && } +
+ ) + } + + return ( +
+
+
+ + {userInfoComponent} +
+ {titleComponent} + {tagsComponent} + {summaryComponent} +
+
+ {metadata.image && ( + + )} +
+ {!embedded && } +
+
+ {t('Open in a', { a: 'Habla' })} +
+
+
+ ) +} diff --git a/src/components/NoteCard/NormalNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx similarity index 63% rename from src/components/NoteCard/NormalNoteCard.tsx rename to src/components/NoteCard/MainNoteCard.tsx index 60f819a8..dcc24197 100644 --- a/src/components/NoteCard/NormalNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -2,15 +2,12 @@ import { Separator } from '@/components/ui/separator' import { useFetchEvent } from '@/hooks' import { getParentEventId, getRootEventId } from '@/lib/event' import { toNote } from '@/lib/link' -import { cn } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' -import { Repeat2 } from 'lucide-react' import { Event } from 'nostr-tools' -import { useTranslation } from 'react-i18next' import Note from '../Note' -import Username from '../Username' +import RepostDescription from './RepostDescription' -export default function NormalNoteCard({ +export default function MainNoteCard({ event, className, reposter, @@ -24,7 +21,6 @@ export default function NormalNoteCard({ const { push } = useSecondaryPage() const { event: rootEvent } = useFetchEvent(getRootEventId(event)) const { event: parentEvent } = useFetchEvent(getParentEventId(event)) - return (
) } - -function RepostDescription({ - reposter, - className -}: { - reposter?: string | null - className?: string -}) { - const { t } = useTranslation() - if (!reposter) return null - - return ( -
- - -
{t('reposted')}
-
- ) -} diff --git a/src/components/NoteCard/RepostDescription.tsx b/src/components/NoteCard/RepostDescription.tsx new file mode 100644 index 00000000..c914492a --- /dev/null +++ b/src/components/NoteCard/RepostDescription.tsx @@ -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 ( +
+ + +
{t('reposted')}
+
+ ) +} diff --git a/src/components/NoteCard/RepostNoteCard.tsx b/src/components/NoteCard/RepostNoteCard.tsx index aee6fe28..ea194317 100644 --- a/src/components/NoteCard/RepostNoteCard.tsx +++ b/src/components/NoteCard/RepostNoteCard.tsx @@ -2,7 +2,7 @@ import { useMuteList } from '@/providers/MuteListProvider' import client from '@/services/client.service' import { Event, kinds, verifyEvent } from 'nostr-tools' import { useMemo } from 'react' -import NormalNoteCard from './NormalNoteCard' +import GenericNoteCard from './GenericNoteCard' export default function RepostNoteCard({ event, @@ -17,7 +17,7 @@ export default function RepostNoteCard({ const targetEvent = useMemo(() => { try { const targetEvent = event.content ? (JSON.parse(event.content) as Event) : null - if (!targetEvent || !verifyEvent(targetEvent) || targetEvent.kind !== kinds.ShortTextNote) { + if (!targetEvent || !verifyEvent(targetEvent) || targetEvent.kind === kinds.Repost) { return null } client.addEventToCache(targetEvent) @@ -38,5 +38,5 @@ export default function RepostNoteCard({ return null } - return + return } diff --git a/src/components/NoteCard/UnknownNoteCard.tsx b/src/components/NoteCard/UnknownNoteCard.tsx new file mode 100644 index 00000000..c579f149 --- /dev/null +++ b/src/components/NoteCard/UnknownNoteCard.tsx @@ -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 ( +
+
+ +
+ +
+ +
+ +
+
+
+
+
{t('Cannot handle event of kind k', { k: event.kind })}
+ +
+
+ {!embedded && } +
+ ) +} diff --git a/src/components/NoteCard/index.tsx b/src/components/NoteCard/index.tsx index b6a8cafd..d20136a2 100644 --- a/src/components/NoteCard/index.tsx +++ b/src/components/NoteCard/index.tsx @@ -1,6 +1,6 @@ import { useMuteList } from '@/providers/MuteListProvider' import { Event, kinds } from 'nostr-tools' -import NormalNoteCard from './NormalNoteCard' +import GenericNoteCard from './GenericNoteCard' import RepostNoteCard from './RepostNoteCard' export default function NoteCard({ @@ -22,5 +22,5 @@ export default function NoteCard({ ) } - return + return } diff --git a/src/constants.ts b/src/constants.ts index cf15a409..778db3ab 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -25,6 +25,7 @@ export const SEARCHABLE_RELAY_URLS = ['wss://relay.nostr.band/', 'wss://search.n export const PICTURE_EVENT_KIND = 20 export const COMMENT_EVENT_KIND = 1111 +export const GROUP_METADATA_EVENT_KIND = 39000 export const URL_REGEX = /https?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_,:!~*]+/gu export const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 57d9d37d..6ea6a4ce 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -178,6 +178,9 @@ export default { randomRelaysRefresh: 'Refresh', 'Explore more': 'Explore more', 'Payment page': 'Payment page', - 'Supported NIPs': 'Supported NIPs' + 'Supported NIPs': 'Supported NIPs', + 'Open in a': 'Open in {{a}}', + 'Cannot handle event of kind k': 'Cannot handle event of kind {{k}}', + 'Sorry! The note cannot be found 😔': 'Sorry! The note cannot be found 😔' } } diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index d570c3c4..db545e44 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -179,6 +179,9 @@ export default { randomRelaysRefresh: '换一批', 'Explore more': '探索更多', 'Payment page': '付款页面', - 'Supported NIPs': '支持的 NIP' + 'Supported NIPs': '支持的 NIP', + 'Open in a': '在 {{a}} 中打开', + 'Cannot handle event of kind k': '无法处理类型为 {{k}} 的事件', + 'Sorry! The note cannot be found 😔': '抱歉!找不到该笔记 😔' } } diff --git a/src/lib/event.ts b/src/lib/event.ts index dd9d3ece..695e1bc4 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -45,6 +45,10 @@ export function isProtectedEvent(event: Event) { return event.tags.some(([tagName]) => tagName === '-') } +export function isSupportedKind(kind: number) { + return [kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(kind) +} + export function getParentEventId(event?: Event) { if (!event || !isReplyNoteEvent(event)) return undefined const tag = event.tags.find(isReplyETag) ?? event.tags.find(tagNameEquals('e')) @@ -81,7 +85,7 @@ export function getEventCoordinate(event: Event) { } export function getSharableEventId(event: Event) { - const hints = client.getEventHints(event.id).slice(0, 3) + const hints = client.getEventHints(event.id).slice(0, 2) if (isReplaceable(event.kind)) { const identifier = event.tags.find(tagNameEquals('d'))?.[1] ?? '' return nip19.naddrEncode({ pubkey: event.pubkey, kind: event.kind, identifier, relays: hints }) diff --git a/src/lib/link.ts b/src/lib/link.ts index 398e6f04..dd9e80e3 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -1,9 +1,12 @@ +import client from '@/services/client.service' import { Event, nip19 } from 'nostr-tools' +import { getSharableEventId } from './event' export const toHome = () => '/' export const toNote = (eventOrId: Pick | string) => { if (typeof eventOrId === 'string') return `/notes/${eventOrId}` - const nevent = nip19.neventEncode({ id: eventOrId.id, author: eventOrId.pubkey }) + const relay = client.getEventHint(eventOrId.id) + const nevent = nip19.neventEncode({ id: eventOrId.id, author: eventOrId.pubkey, relays: [relay] }) return `/notes/${nevent}` } export const toNoteList = ({ hashtag, search }: { hashtag?: string; search?: string }) => { @@ -40,7 +43,10 @@ export const toProfileEditor = () => '/profile-editor' export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}` export const toMuteList = () => '/mutes' -export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}` -export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}` -export const toNoStrudelArticle = (id: string) => `https://nostrudel.ninja/#/articles/${id}` -export const toNoStrudelStream = (id: string) => `https://nostrudel.ninja/#/streams/${id}` +export const toHablaLongFormArticle = (event: Event) => { + return `https://habla.news/a/${getSharableEventId(event)}` +} +export const toZapStreamLiveEvent = (event: Event) => { + return `https://zap.stream/${getSharableEventId(event)}` +} +export const toChachiChat = (relay: string, d: string) => `https://chachi.chat/${relay}/${d}` diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 66cd2a9e..bd553950 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -12,6 +12,7 @@ import { useFetchEvent } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { getParentEventId, getRootEventId, isPictureEvent } from '@/lib/event' import { toNote } from '@/lib/link' +import { kinds } from 'nostr-tools' import { forwardRef, useMemo } from 'react' import { useTranslation } from 'react-i18next' import NotFoundPage from '../NotFoundPage' @@ -57,15 +58,15 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
- {isPictureEvent(event) ? ( + {event.kind === kinds.ShortTextNote ? ( + + ) : isPictureEvent(event) ? ( - ) : ( - - )} + ) : null} ) })