From ec2ac2dce386a113b993229ebb5e520dfd3ba883 Mon Sep 17 00:00:00 2001 From: reya Date: Tue, 19 Dec 2023 08:06:10 +0700 Subject: [PATCH] feat(ark): add note component to ark --- src/app/home/index.tsx | 24 +- src/libs/ark/components/note/child.tsx | 57 +++++ src/libs/ark/components/note/childUser.tsx | 86 +++++++ src/libs/ark/components/note/content.tsx | 11 + src/libs/ark/components/note/index.ts | 19 ++ src/libs/ark/components/note/kinds/text.tsx | 40 ++++ src/libs/ark/components/note/reaction.tsx | 122 ++++++++++ src/libs/ark/components/note/reply.tsx | 43 ++++ src/libs/ark/components/note/repost.tsx | 50 ++++ src/libs/ark/components/note/root.tsx | 18 ++ src/libs/ark/components/note/user.tsx | 143 +++++++++++ src/libs/ark/components/note/zap.tsx | 252 ++++++++++++++++++++ src/libs/ark/index.ts | 5 +- src/shared/icons/horizontalDots.tsx | 24 +- src/shared/icons/reaction.tsx | 23 +- src/shared/icons/reply.tsx | 27 +-- src/shared/icons/repost.tsx | 17 +- src/shared/icons/zap.tsx | 20 +- src/shared/notes/text.tsx | 2 +- 19 files changed, 898 insertions(+), 85 deletions(-) create mode 100644 src/libs/ark/components/note/child.tsx create mode 100644 src/libs/ark/components/note/childUser.tsx create mode 100644 src/libs/ark/components/note/content.tsx create mode 100644 src/libs/ark/components/note/index.ts create mode 100644 src/libs/ark/components/note/kinds/text.tsx create mode 100644 src/libs/ark/components/note/reaction.tsx create mode 100644 src/libs/ark/components/note/reply.tsx create mode 100644 src/libs/ark/components/note/repost.tsx create mode 100644 src/libs/ark/components/note/root.tsx create mode 100644 src/libs/ark/components/note/user.tsx create mode 100644 src/libs/ark/components/note/zap.tsx diff --git a/src/app/home/index.tsx b/src/app/home/index.tsx index c181a7d7..37a39167 100644 --- a/src/app/home/index.tsx +++ b/src/app/home/index.tsx @@ -20,7 +20,7 @@ import { WidgetList, } from '@shared/widgets'; import { WIDGET_KIND } from '@utils/constants'; -import { Widget } from '@utils/types'; +import { WidgetProps } from '@utils/types'; export function HomeScreen() { const ark = useArk(); @@ -54,32 +54,32 @@ export function HomeScreen() { staleTime: Infinity, }); - const renderItem = (widget: Widget) => { + const renderItem = (widget: WidgetProps) => { switch (widget.kind) { case WIDGET_KIND.notification: return ; case WIDGET_KIND.newsfeed: return ; case WIDGET_KIND.topic: - return ; + return ; case WIDGET_KIND.user: - return ; + return ; case WIDGET_KIND.thread: - return ; + return ; case WIDGET_KIND.article: - return ; + return ; case WIDGET_KIND.file: - return ; + return ; case WIDGET_KIND.hashtag: - return ; + return ; case WIDGET_KIND.group: - return ; + return ; case WIDGET_KIND.trendingNotes: - return ; + return ; case WIDGET_KIND.trendingAccounts: - return ; + return ; case WIDGET_KIND.list: - return ; + return ; default: return ; } diff --git a/src/libs/ark/components/note/child.tsx b/src/libs/ark/components/note/child.tsx new file mode 100644 index 00000000..63c2e611 --- /dev/null +++ b/src/libs/ark/components/note/child.tsx @@ -0,0 +1,57 @@ +import { useQuery } from '@tanstack/react-query'; +import { useArk } from '@libs/ark/provider'; +import { NoteChildUser } from './childUser'; + +export function NoteChild({ eventId, isRoot }: { eventId: string; isRoot?: boolean }) { + const ark = useArk(); + const { isLoading, isError, data } = useQuery({ + queryKey: ['event', eventId], + queryFn: async () => { + // get event from relay + const event = await ark.getEventById({ id: eventId }); + + if (!event) + throw new Error( + `Cannot get event with ${eventId}, will be retry after 10 seconds` + ); + + return event; + }, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + retry: 2, + }); + + if (isLoading) { + return ( +
+
+
+
+
+ ); + } + + if (isError) { + return ( +
+
+ Failed to fetch event +
+
+ ); + } + + return ( +
+
+
+
+ {data.content} +
+
+ +
+ ); +} diff --git a/src/libs/ark/components/note/childUser.tsx b/src/libs/ark/components/note/childUser.tsx new file mode 100644 index 00000000..45b4f66d --- /dev/null +++ b/src/libs/ark/components/note/childUser.tsx @@ -0,0 +1,86 @@ +import * as Avatar from '@radix-ui/react-avatar'; +import { useQuery } from '@tanstack/react-query'; +import { minidenticon } from 'minidenticons'; +import { useMemo } from 'react'; +import { useArk } from '@libs/ark/provider'; +import { displayNpub } from '@utils/formater'; + +export function NoteChildUser({ pubkey, subtext }: { pubkey: string; subtext: string }) { + const ark = useArk(); + const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]); + const fallbackAvatar = useMemo( + () => 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50)), + [pubkey] + ); + + const { isLoading, data: user } = useQuery({ + queryKey: ['user', pubkey], + queryFn: async () => { + try { + const profile = await ark.getUserProfile({ pubkey }); + + if (!profile) + throw new Error( + `Cannot get metadata for ${pubkey}, will be retry after 10 seconds` + ); + + return profile; + } catch (e) { + throw new Error(e); + } + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: 2, + }); + + if (isLoading) { + return ( + <> + + + +
+
{fallbackName}
+
+ {subtext}: +
+
+ + ); + } + + return ( + <> + + + + {pubkey} + + +
+
+ {user?.display_name || user?.name || user?.displayName || fallbackName}{' '} +
+
+ {subtext}: +
+
+ + ); +} diff --git a/src/libs/ark/components/note/content.tsx b/src/libs/ark/components/note/content.tsx new file mode 100644 index 00000000..647dfada --- /dev/null +++ b/src/libs/ark/components/note/content.tsx @@ -0,0 +1,11 @@ +import { useRichContent } from '@utils/hooks/useRichContent'; + +export function NoteContent({ content }: { content: string }) { + const { parsedContent } = useRichContent(content); + + return ( +
+ {parsedContent} +
+ ); +} diff --git a/src/libs/ark/components/note/index.ts b/src/libs/ark/components/note/index.ts new file mode 100644 index 00000000..f8a89f84 --- /dev/null +++ b/src/libs/ark/components/note/index.ts @@ -0,0 +1,19 @@ +import { NoteChild } from './child'; +import { NoteContent } from './content'; +import { NoteReaction } from './reaction'; +import { NoteReply } from './reply'; +import { NoteRepost } from './repost'; +import { NoteRoot } from './root'; +import { NoteUser } from './user'; +import { NoteZap } from './zap'; + +export const Note = { + Root: NoteRoot, + User: NoteUser, + Content: NoteContent, + Reply: NoteReply, + Repost: NoteRepost, + Reaction: NoteReaction, + Zap: NoteZap, + Child: NoteChild, +}; diff --git a/src/libs/ark/components/note/kinds/text.tsx b/src/libs/ark/components/note/kinds/text.tsx new file mode 100644 index 00000000..edb45443 --- /dev/null +++ b/src/libs/ark/components/note/kinds/text.tsx @@ -0,0 +1,40 @@ +import { NDKEvent } from '@nostr-dev-kit/ndk'; +import { useArk } from '@libs/ark/provider'; +import { Note } from '..'; + +export function TextNote({ event }: { event: NDKEvent }) { + const ark = useArk(); + const thread = ark.getEventThread({ tags: event.tags }); + + return ( + + + {thread ? ( +
+
+ {thread.rootEventId ? ( + + ) : null} + {thread.replyEventId ? : null} + +
+
+ ) : null} + +
+
+
+ + + + +
+
+ + ); +} diff --git a/src/libs/ark/components/note/reaction.tsx b/src/libs/ark/components/note/reaction.tsx new file mode 100644 index 00000000..5cbc7ff9 --- /dev/null +++ b/src/libs/ark/components/note/reaction.tsx @@ -0,0 +1,122 @@ +import { NDKEvent } from '@nostr-dev-kit/ndk'; +import * as Popover from '@radix-ui/react-popover'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { ReactionIcon } from '@shared/icons'; + +const REACTIONS = [ + { + content: '👏', + img: '/clapping_hands.png', + }, + { + content: '🤪', + img: '/face_with_tongue.png', + }, + { + content: '😮', + img: '/face_with_open_mouth.png', + }, + { + content: '😢', + img: '/crying_face.png', + }, + { + content: '🤡', + img: '/clown_face.png', + }, +]; + +export function NoteReaction({ event }: { event: NDKEvent }) { + const [open, setOpen] = useState(false); + const [reaction, setReaction] = useState(null); + + const getReactionImage = (content: string) => { + const reaction: { img: string } = REACTIONS.find((el) => el.content === content); + return reaction.img; + }; + + const react = async (content: string) => { + try { + setReaction(content); + + // react + await event.react(content); + + setOpen(false); + } catch (e) { + toast.error(e); + } + }; + + return ( + + + + + + +
+ + + + + +
+ +
+
+
+ ); +} diff --git a/src/libs/ark/components/note/reply.tsx b/src/libs/ark/components/note/reply.tsx new file mode 100644 index 00000000..cc7dbd51 --- /dev/null +++ b/src/libs/ark/components/note/reply.tsx @@ -0,0 +1,43 @@ +import * as Tooltip from '@radix-ui/react-tooltip'; +import { createSearchParams, useNavigate } from 'react-router-dom'; +import { ReplyIcon } from '@shared/icons'; + +export function NoteReply({ + eventId, + rootEventId, +}: { + eventId: string; + rootEventId?: string; +}) { + const navigate = useNavigate(); + + return ( + + + + + + + + Quick reply + + + + + + ); +} diff --git a/src/libs/ark/components/note/repost.tsx b/src/libs/ark/components/note/repost.tsx new file mode 100644 index 00000000..35c54751 --- /dev/null +++ b/src/libs/ark/components/note/repost.tsx @@ -0,0 +1,50 @@ +import { NDKEvent } from '@nostr-dev-kit/ndk'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { twMerge } from 'tailwind-merge'; +import { RepostIcon } from '@shared/icons'; + +export function NoteRepost({ event }: { event: NDKEvent }) { + const [isRepost, setIsRepost] = useState(false); + + const submit = async () => { + try { + // repost + await event.repost(true); + + // update state + setIsRepost(true); + toast.success("You've reposted this post successfully"); + } catch (e) { + toast.error('Repost failed, try again later'); + } + }; + + return ( + + + + + + + + Repost + + + + + + ); +} diff --git a/src/libs/ark/components/note/root.tsx b/src/libs/ark/components/note/root.tsx new file mode 100644 index 00000000..59591e01 --- /dev/null +++ b/src/libs/ark/components/note/root.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from 'react'; +import { twMerge } from 'tailwind-merge'; + +export function NoteRoot({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/libs/ark/components/note/user.tsx b/src/libs/ark/components/note/user.tsx new file mode 100644 index 00000000..8eb31e79 --- /dev/null +++ b/src/libs/ark/components/note/user.tsx @@ -0,0 +1,143 @@ +import * as Avatar from '@radix-ui/react-avatar'; +import { useQuery } from '@tanstack/react-query'; +import { minidenticon } from 'minidenticons'; +import { useMemo } from 'react'; +import { useArk } from '@libs/ark'; +import { RepostIcon } from '@shared/icons'; +import { displayNpub, formatCreatedAt } from '@utils/formater'; + +export function NoteUser({ + pubkey, + time, + variant = 'text', +}: { + pubkey: string; + time: number; + variant?: 'text' | 'repost'; +}) { + const ark = useArk(); + const createdAt = useMemo(() => formatCreatedAt(time), [time]); + const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]); + const fallbackAvatar = useMemo( + () => 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50)), + [pubkey] + ); + + const { isLoading, data: user } = useQuery({ + queryKey: ['user', pubkey], + queryFn: async () => { + try { + const profile = await ark.getUserProfile({ pubkey }); + + if (!profile) + throw new Error( + `Cannot get metadata for ${pubkey}, will be retry after 10 seconds` + ); + + return profile; + } catch (e) { + throw new Error(e); + } + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: 2, + }); + + if (variant === 'repost') { + if (isLoading) { + return ( +
+
+ +
+
+
+
+
+
+ ); + } + + return ( +
+
+ +
+
+ + + + {pubkey} + + +
+
+ {user?.name || user?.display_name || user?.displayName || fallbackName} +
+ reposted +
+
+
+ ); + } + + if (isLoading) { + return ( +
+ + + +
+
+ {fallbackName} +
+
+
+ ); + } + + return ( +
+ + + + {pubkey} + + +
+
+ {user?.name || user?.display_name || user?.displayName || fallbackName} +
+
+
{createdAt}
+
+
+
+ ); +} diff --git a/src/libs/ark/components/note/zap.tsx b/src/libs/ark/components/note/zap.tsx new file mode 100644 index 00000000..fcab79b3 --- /dev/null +++ b/src/libs/ark/components/note/zap.tsx @@ -0,0 +1,252 @@ +import { webln } from '@getalby/sdk'; +import { SendPaymentResponse } from '@getalby/sdk/dist/types'; +import { NDKEvent } from '@nostr-dev-kit/ndk'; +import * as Dialog from '@radix-ui/react-dialog'; +import { invoke } from '@tauri-apps/api/primitives'; +import { message } from '@tauri-apps/plugin-dialog'; +import { QRCodeSVG } from 'qrcode.react'; +import { useEffect, useRef, useState } from 'react'; +import CurrencyInput from 'react-currency-input-field'; +import { useNavigate } from 'react-router-dom'; +import { useArk } from '@libs/ark'; +import { CancelIcon, ZapIcon } from '@shared/icons'; +import { compactNumber, displayNpub } from '@utils/formater'; +import { useProfile } from '@utils/hooks/useProfile'; +import { sendNativeNotification } from '@utils/notification'; + +export function NoteZap({ event }: { event: NDKEvent }) { + const [walletConnectURL, setWalletConnectURL] = useState(null); + const [amount, setAmount] = useState('21'); + const [zapMessage, setZapMessage] = useState(''); + const [invoice, setInvoice] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const [isCompleted, setIsCompleted] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const { user } = useProfile(event.pubkey); + + const ark = useArk(); + const nwc = useRef(null); + const navigate = useNavigate(); + + const createZapRequest = async () => { + try { + if (!ark.readyToSign) return navigate('/new/privkey'); + + const zapAmount = parseInt(amount) * 1000; + const res = await event.zap(zapAmount, zapMessage); + + if (!res) + return await message('Cannot create zap request', { + title: 'Zap', + type: 'error', + }); + + // user don't connect nwc, create QR Code for invoice + if (!walletConnectURL) return setInvoice(res); + + // user connect nwc + nwc.current = new webln.NostrWebLNProvider({ + nostrWalletConnectUrl: walletConnectURL, + }); + await nwc.current.enable(); + + // start loading + setIsLoading(true); + // send payment via nwc + const send: SendPaymentResponse = await nwc.current.sendPayment(res); + + if (send) { + await sendNativeNotification( + `You've tipped ${compactNumber.format(send.amount)} sats to ${ + user?.name || user?.display_name || user?.displayName + }` + ); + + // eose + nwc.current.close(); + setIsCompleted(true); + setIsLoading(false); + + // reset after 3 secs + const timeout = setTimeout(() => setIsCompleted(false), 3000); + clearTimeout(timeout); + } + } catch (e) { + nwc.current.close(); + setIsLoading(false); + await message(JSON.stringify(e), { title: 'Zap', type: 'error' }); + } + }; + + useEffect(() => { + async function getWalletConnectURL() { + const uri: string = await invoke('secure_load', { + key: `${ark.account.pubkey}-nwc`, + }); + if (uri) setWalletConnectURL(uri); + } + + if (isOpen) getWalletConnectURL(); + + return () => { + setAmount('21'); + setZapMessage(''); + setIsCompleted(false); + setIsLoading(false); + }; + }, [isOpen]); + + return ( + + + + + + + +
+
+
+ + Send tip to{' '} + {user?.name || user?.displayName || displayNpub(event.pubkey, 16)} + + + + +
+
+ {!invoice ? ( + <> +
+
+ setAmount(value)} + className="w-full flex-1 border-none bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400" + /> + + sats + +
+
+ + + + + +
+
+
+ setZapMessage(e.target.value)} + spellCheck={false} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + placeholder="Enter message (optional)" + className="w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-400" + /> +
+ {walletConnectURL ? ( + + ) : ( + + )} +
+
+ + ) : ( +
+
+ +
+
+

Scan to zap

+ + You must use Bitcoin wallet which support Lightning +
+ such as: Blue Wallet, Bitkit, Phoenix,... +
+
+
+ )} +
+
+ + + + ); +} diff --git a/src/libs/ark/index.ts b/src/libs/ark/index.ts index a988345e..48c8ef3b 100644 --- a/src/libs/ark/index.ts +++ b/src/libs/ark/index.ts @@ -1,6 +1,5 @@ export * from './ark'; export * from './provider'; export * from './components/widget'; -export * from './components/widget/content'; -export * from './components/widget/header'; -export * from './components/widget/root'; +export * from './components/note'; +export * from './components/note/kinds/text'; diff --git a/src/shared/icons/horizontalDots.tsx b/src/shared/icons/horizontalDots.tsx index 19eecef7..de8974e1 100644 --- a/src/shared/icons/horizontalDots.tsx +++ b/src/shared/icons/horizontalDots.tsx @@ -1,24 +1,20 @@ -import { SVGProps } from 'react'; - -export function HorizontalDotsIcon( - props: JSX.IntrinsicAttributes & SVGProps -) { +export function HorizontalDotsIcon(props: JSX.IntrinsicElements['svg']) { return ( - + + + ); } diff --git a/src/shared/icons/reaction.tsx b/src/shared/icons/reaction.tsx index 2d16f037..ff73ba22 100644 --- a/src/shared/icons/reaction.tsx +++ b/src/shared/icons/reaction.tsx @@ -1,25 +1,18 @@ -import { SVGProps } from 'react'; - -export function ReactionIcon(props: JSX.IntrinsicAttributes & SVGProps) { +export function ReactionIcon(props: JSX.IntrinsicElements['svg']) { return ( - - + ); } diff --git a/src/shared/icons/reply.tsx b/src/shared/icons/reply.tsx index a85c623b..2e781c96 100644 --- a/src/shared/icons/reply.tsx +++ b/src/shared/icons/reply.tsx @@ -1,29 +1,18 @@ -import { SVGProps } from 'react'; - -export function ReplyIcon(props: JSX.IntrinsicAttributes & SVGProps) { +export function ReplyIcon(props: JSX.IntrinsicElements['svg']) { return ( - - + ); } diff --git a/src/shared/icons/repost.tsx b/src/shared/icons/repost.tsx index e0a93d6d..bd2ef69c 100644 --- a/src/shared/icons/repost.tsx +++ b/src/shared/icons/repost.tsx @@ -1,19 +1,18 @@ -import { SVGProps } from 'react'; - -export function RepostIcon(props: JSX.IntrinsicAttributes & SVGProps) { +export function RepostIcon(props: JSX.IntrinsicElements['svg']) { return ( - + ); } diff --git a/src/shared/icons/zap.tsx b/src/shared/icons/zap.tsx index 39633453..56a8ca29 100644 --- a/src/shared/icons/zap.tsx +++ b/src/shared/icons/zap.tsx @@ -1,22 +1,18 @@ -import { SVGProps } from 'react'; - -export function ZapIcon(props: JSX.IntrinsicAttributes & SVGProps) { +export function ZapIcon(props: JSX.IntrinsicElements['svg']) { return ( - + ); } diff --git a/src/shared/notes/text.tsx b/src/shared/notes/text.tsx index 007cc336..b439962e 100644 --- a/src/shared/notes/text.tsx +++ b/src/shared/notes/text.tsx @@ -16,7 +16,7 @@ export function TextNote({ event, className }: { event: NDKEvent; className?: st const thread = ark.getEventThread({ tags: event.tags }); return ( -
+
{thread ? (