From 5606dcb32fd876e98a11857bf964512a23a528d8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya <123083837+reyamir@users.noreply.github.com> Date: Mon, 17 Jul 2023 13:37:01 +0700 Subject: [PATCH] update replies --- src/app/note/index.tsx | 15 ++--- src/app/space/components/blocks/thread.tsx | 46 +++++-------- src/libs/storage.tsx | 4 +- src/shared/notes/index.tsx | 2 + src/shared/notes/mentions/user.tsx | 2 +- src/shared/notes/replies/item.tsx | 22 +++++-- src/shared/notes/replies/list.tsx | 20 ++++-- src/shared/notes/stats.tsx | 76 ++++++++++++++++++++++ src/shared/notes/users/thread.tsx | 46 +++++++++++++ src/utils/shortenKey.tsx | 14 ++++ src/utils/transform.tsx | 2 +- 11 files changed, 193 insertions(+), 56 deletions(-) create mode 100644 src/shared/notes/stats.tsx create mode 100644 src/shared/notes/users/thread.tsx diff --git a/src/app/note/index.tsx b/src/app/note/index.tsx index 20016f4e..bd019934 100644 --- a/src/app/note/index.tsx +++ b/src/app/note/index.tsx @@ -1,10 +1,7 @@ -import { useQuery } from '@tanstack/react-query'; import { useParams } from 'react-router-dom'; import { useLiveThread } from '@app/space/hooks/useLiveThread'; -import { getNoteByID } from '@libs/storage'; - import { NoteMetadata } from '@shared/notes/metadata'; import { NoteReplyForm } from '@shared/notes/replies/form'; import { RepliesList } from '@shared/notes/replies/list'; @@ -12,16 +9,12 @@ import { NoteSkeleton } from '@shared/notes/skeleton'; import { User } from '@shared/user'; import { useAccount } from '@utils/hooks/useAccount'; -import { parser } from '@utils/parser'; +import { useEvent } from '@utils/hooks/useEvent'; export function NoteScreen() { const { id } = useParams(); const { account } = useAccount(); - const { status, data } = useQuery(['thread', id], async () => { - const res = await getNoteByID(id); - res['content'] = parser(res); - return res; - }); + const { status, data } = useEvent(id); useLiveThread(id); @@ -39,7 +32,7 @@ export function NoteScreen() { <div className="rounded-md bg-zinc-900 px-5 pt-5"> <User pubkey={data.pubkey} time={data.created_at} /> <div className="mt-3"> - <NoteMetadata id={data.event_id || id} eventPubkey={data.pubkey} /> + <NoteMetadata id={data.event_id || id} /> </div> </div> <div className="mt-3 rounded-md bg-zinc-900"> @@ -48,7 +41,7 @@ export function NoteScreen() { </div> )} <div className="px-3"> - <RepliesList parent_id={id} /> + <RepliesList id={id} /> </div> </div> </div> diff --git a/src/app/space/components/blocks/thread.tsx b/src/app/space/components/blocks/thread.tsx index a3fe7b06..f1e37821 100644 --- a/src/app/space/components/blocks/thread.tsx +++ b/src/app/space/components/blocks/thread.tsx @@ -1,31 +1,20 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Link } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useLiveThread } from '@app/space/hooks/useLiveThread'; +// import { useLiveThread } from '@app/space/hooks/useLiveThread'; +import { removeBlock } from '@libs/storage'; -import { getNoteByID, removeBlock } from '@libs/storage'; - -import { NoteReplyForm } from '@shared/notes/replies/form'; +import { NoteContent, NoteStats, ThreadUser } from '@shared/notes'; import { RepliesList } from '@shared/notes/replies/list'; import { NoteSkeleton } from '@shared/notes/skeleton'; import { TitleBar } from '@shared/titleBar'; -import { User } from '@shared/user'; -import { useAccount } from '@utils/hooks/useAccount'; -import { parser } from '@utils/parser'; +import { useEvent } from '@utils/hooks/useEvent'; import { Block } from '@utils/types'; export function ThreadBlock({ params }: { params: Block }) { - useLiveThread(params.content); - const queryClient = useQueryClient(); - const { account } = useAccount(); - const { status, data } = useQuery(['thread', params.content], async () => { - const res = await getNoteByID(params.content); - res['content'] = parser(res); - return res; - }); + const { status, data } = useEvent(params.content); const block = useMutation({ mutationFn: (id: string) => { @@ -36,33 +25,34 @@ export function ThreadBlock({ params }: { params: Block }) { }, }); + // subscribe to live reply + // useLiveThread(params.content); + return ( <div className="w-[400px] shrink-0 border-r border-zinc-900"> <TitleBar title={params.title} onClick={() => block.mutate(params.id)} /> <div className="scrollbar-hide flex h-full w-full flex-col gap-1.5 overflow-y-auto pb-20 pt-1.5"> {status === 'loading' ? ( <div className="px-3 py-1.5"> - <div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20"> + <div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3"> <NoteSkeleton /> </div> </div> ) : ( <div className="h-min w-full px-3 py-1.5"> - <div className="rounded-md bg-zinc-900 px-5 pt-5"> - <User pubkey={data.pubkey} time={data.created_at} /> - <div className="mt-3"> - <Link to={`/app/note/${params.content}`}>Focus</Link> + <div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3"> + <ThreadUser pubkey={data.pubkey} time={data.created_at} /> + <div className="mt-2"> + <NoteContent content={data.content} /> + </div> + <div> + <NoteStats id={data.id} /> </div> - </div> - <div className="mt-3 rounded-md bg-zinc-900"> - {account && ( - <NoteReplyForm rootID={params.content} userPubkey={account.pubkey} /> - )} </div> </div> )} <div className="px-3"> - <RepliesList parent_id={params.content} /> + <RepliesList id={params.content} /> </div> </div> </div> diff --git a/src/libs/storage.tsx b/src/libs/storage.tsx index 0d568fc8..61369494 100644 --- a/src/libs/storage.tsx +++ b/src/libs/storage.tsx @@ -169,7 +169,7 @@ export async function createNote( event_id: string, pubkey: string, kind: number, - tags: string[], + tags: string[][], content: string, created_at: number ) { @@ -186,7 +186,7 @@ export async function createNote( // get note replies export async function getReplies(parent_id: string) { const db = await connect(); - const result: any = await db.select( + const result: Array<LumeEvent> = await db.select( `SELECT * FROM replies WHERE parent_id = "${parent_id}" ORDER BY created_at DESC;` ); return result; diff --git a/src/shared/notes/index.tsx b/src/shared/notes/index.tsx index a9ba8762..0f7531e8 100644 --- a/src/shared/notes/index.tsx +++ b/src/shared/notes/index.tsx @@ -15,6 +15,7 @@ export * from './kinds/kind1063'; export * from './metadata'; export * from './users/mini'; export * from './users/repost'; +export * from './users/thread'; export * from './kinds/thread'; export * from './kinds/repost'; export * from './kinds/sub'; @@ -22,3 +23,4 @@ export * from './skeleton'; export * from './actions'; export * from './content'; export * from './hashtag'; +export * from './stats'; diff --git a/src/shared/notes/mentions/user.tsx b/src/shared/notes/mentions/user.tsx index d2e9bd36..c0ddf550 100644 --- a/src/shared/notes/mentions/user.tsx +++ b/src/shared/notes/mentions/user.tsx @@ -9,7 +9,7 @@ export function MentionUser({ pubkey }: { pubkey: string }) { type="button" className="break-words rounded bg-zinc-800 px-2 py-px text-sm font-normal text-blue-400 no-underline hover:bg-zinc-700 hover:text-blue-500" > - @{user?.name || user?.displayName || shortenKey(pubkey)} + {'@' + user?.name || user?.displayName || shortenKey(pubkey)} </button> ); } diff --git a/src/shared/notes/replies/item.tsx b/src/shared/notes/replies/item.tsx index c6718bd8..98b5d19b 100644 --- a/src/shared/notes/replies/item.tsx +++ b/src/shared/notes/replies/item.tsx @@ -1,17 +1,25 @@ -import { NoteMetadata } from '@shared/notes/metadata'; +import { NoteActions, NoteContent } from '@shared/notes'; import { User } from '@shared/user'; import { parser } from '@utils/parser'; +import { LumeEvent } from '@utils/types'; -export function Reply({ data }: { data: any }) { +export function Reply({ data }: { data: LumeEvent }) { const content = parser(data); return ( - <div className="mb-3 flex h-min min-h-min w-full select-text flex-col rounded-md bg-zinc-900 px-3 pt-5"> - <div className="flex flex-col"> - <User pubkey={data.pubkey} time={data.created_at} /> - <div className="-mt-[20px] pl-[50px]"> - <NoteMetadata id={data.event_id} eventPubkey={data.pubkey} /> + <div className="h-min w-full py-1.5"> + <div className="relative overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3"> + <div className="relative flex flex-col"> + <User pubkey={data.pubkey} time={data.created_at} /> + <div className="relative z-20 -mt-6 flex items-start gap-3"> + <div className="w-11 shrink-0" /> + <div className="flex-1"> + <NoteContent content={content} /> + <NoteActions id={data.event_id || data.id} pubkey={data.pubkey} /> + </div> + </div> + <div className="pb-3" /> </div> </div> </div> diff --git a/src/shared/notes/replies/list.tsx b/src/shared/notes/replies/list.tsx index d27b6622..7be43a9a 100644 --- a/src/shared/notes/replies/list.tsx +++ b/src/shared/notes/replies/list.tsx @@ -1,17 +1,25 @@ import { NDKEvent } from '@nostr-dev-kit/ndk'; import { useQuery } from '@tanstack/react-query'; -import { getReplies } from '@libs/storage'; +import { useNDK } from '@libs/ndk/provider'; import { Reply } from '@shared/notes/replies/item'; -export function RepliesList({ parent_id }: { parent_id: string }) { - const { status, data } = useQuery(['replies', parent_id], async () => { - return await getReplies(parent_id); +import { LumeEvent } from '@utils/types'; + +export function RepliesList({ id }: { id: string }) { + const { relayUrls, fetcher } = useNDK(); + const { status, data } = useQuery(['thread', id], async () => { + const events = (await fetcher.fetchAllEvents( + relayUrls, + { kinds: [1], '#e': [id] }, + { since: 0 } + )) as unknown as LumeEvent[]; + return events; }); return ( - <div className="mt-5"> + <div className="mt-3"> <div className="mb-2"> <h5 className="text-lg font-semibold text-zinc-300">Replies</h5> </div> @@ -28,7 +36,7 @@ export function RepliesList({ parent_id }: { parent_id: string }) { </div> ) : data.length === 0 ? ( <div className="px=3"> - <div className="flex w-full items-center justify-center rounded-md bg-zinc-900"> + <div className="flex w-full items-center justify-center rounded-xl bg-zinc-900"> <div className="flex flex-col items-center justify-center gap-2 py-6"> <h3 className="text-3xl">๐</h3> <p className="leading-none text-zinc-400">Share your thought on it...</p> diff --git a/src/shared/notes/stats.tsx b/src/shared/notes/stats.tsx new file mode 100644 index 00000000..ead08097 --- /dev/null +++ b/src/shared/notes/stats.tsx @@ -0,0 +1,76 @@ +import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk'; +import { useQuery } from '@tanstack/react-query'; +import { decode } from 'light-bolt11-decoder'; + +import { useNDK } from '@libs/ndk/provider'; + +import { LoaderIcon } from '@shared/icons'; + +export function NoteStats({ id }: { id: string }) { + const { ndk } = useNDK(); + const { status, data } = useQuery( + ['note-stats', id], + async () => { + let reactions = 0; + let reposts = 0; + let zaps = 0; + + const filter: NDKFilter = { + '#e': [id], + kinds: [6, 7, 9735], + }; + + const events = await ndk.fetchEvents(filter); + events.forEach((event: NDKEvent) => { + switch (event.kind) { + case 6: + reposts += 1; + break; + case 7: + reactions += 1; + break; + case 9735: { + const bolt11 = event.tags.find((tag) => tag[0] === 'bolt11')[1]; + if (bolt11) { + const decoded = decode(bolt11); + const amount = decoded.sections.find((item) => item.name === 'amount'); + const sats = amount.value / 1000; + zaps += sats; + } + break; + } + default: + break; + } + }); + + return { reposts, reactions, zaps }; + }, + { refetchOnWindowFocus: false, refetchOnReconnect: false } + ); + + if (status === 'loading') { + return ( + <div className="flex h-11 items-center"> + <LoaderIcon className="h-4 w-4 animate-spin text-zinc-100" /> + </div> + ); + } + + return ( + <div className="flex h-11 items-center gap-3"> + <p className="inline-flex h-6 items-center justify-center gap-1 rounded bg-zinc-800 px-2 text-sm"> + {data.reactions} + <span className="text-zinc-400">reactions</span> + </p> + <p className="inline-flex h-6 items-center justify-center gap-1 rounded bg-zinc-800 px-2 text-sm"> + {data.reposts} + <span className="text-zinc-400">reposts</span> + </p> + <p className="inline-flex h-6 items-center justify-center gap-1 rounded bg-zinc-800 px-2 text-sm"> + {data.zaps} + <span className="text-zinc-400">zaps</span> + </p> + </div> + ); +} diff --git a/src/shared/notes/users/thread.tsx b/src/shared/notes/users/thread.tsx new file mode 100644 index 00000000..c7ee4346 --- /dev/null +++ b/src/shared/notes/users/thread.tsx @@ -0,0 +1,46 @@ +import { VerticalDotsIcon } from '@shared/icons'; +import { Image } from '@shared/image'; + +import { DEFAULT_AVATAR } from '@stores/constants'; + +import { formatCreatedAt } from '@utils/createdAt'; +import { useProfile } from '@utils/hooks/useProfile'; +import { displayNpub } from '@utils/shortenKey'; + +export function ThreadUser({ pubkey, time }: { pubkey: string; time: number }) { + const { status, user } = useProfile(pubkey); + const createdAt = formatCreatedAt(time); + + if (status === 'loading') { + return <div className="h-4 w-4 animate-pulse rounded bg-zinc-700"></div>; + } + + return ( + <div className="flex items-center gap-3"> + <Image + src={user?.picture || user?.image || DEFAULT_AVATAR} + fallback={DEFAULT_AVATAR} + alt={pubkey} + className="relative z-20 inline-block h-11 w-11 rounded-lg" + /> + <div className="lex flex-1 items-baseline justify-between"> + <div className="inline-flex w-full items-center justify-between"> + <h5 className="truncate font-semibold leading-none text-zinc-100"> + {user?.nip05?.toLowerCase() || user?.name || user?.display_name} + </h5> + <button + type="button" + className="inline-flex h-5 w-max items-center justify-center rounded px-1 hover:bg-zinc-800" + > + <VerticalDotsIcon className="h-4 w-4 rotate-90 transform text-zinc-200" /> + </button> + </div> + <div className="inline-flex items-center gap-2"> + <span className="leading-none text-zinc-500">{createdAt}</span> + <span className="leading-none text-zinc-500">ยท</span> + <span className="leading-none text-zinc-500">{displayNpub(pubkey, 16)}</span> + </div> + </div> + </div> + ); +} diff --git a/src/utils/shortenKey.tsx b/src/utils/shortenKey.tsx index 4e90b4f5..cf950fd3 100644 --- a/src/utils/shortenKey.tsx +++ b/src/utils/shortenKey.tsx @@ -4,3 +4,17 @@ export function shortenKey(pubkey: string) { const npub = nip19.npubEncode(pubkey); return npub.substring(0, 16).concat('...'); } + +export function displayNpub(pubkey: string, len: number, separator?: string) { + const npub = nip19.npubEncode(pubkey) as string; + if (npub.length <= len) return npub; + + separator = separator || ' ... '; + + const sepLen = separator.length, + charsToShow = len - sepLen, + frontChars = Math.ceil(charsToShow / 2), + backChars = Math.floor(charsToShow / 2); + + return npub.substr(0, frontChars) + separator + npub.substr(npub.length - backChars); +} diff --git a/src/utils/transform.tsx b/src/utils/transform.tsx index e4f771a6..7068694f 100644 --- a/src/utils/transform.tsx +++ b/src/utils/transform.tsx @@ -47,7 +47,7 @@ export function arrayObjToPureArr(arr: any) { } // get parent id from event tags -export function getParentID(arr: string[], fallback: string) { +export function getParentID(arr: string[][], fallback: string) { const tags = destr(arr); let parentID = fallback;