feat(ark): add note component to ark

This commit is contained in:
reya
2023-12-19 08:06:10 +07:00
parent 55298515af
commit ec2ac2dce3
19 changed files with 898 additions and 85 deletions

View File

@@ -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 <NotificationWidget key={widget.id} />;
case WIDGET_KIND.newsfeed:
return <NewsfeedWidget key={widget.id} />;
case WIDGET_KIND.topic:
return <TopicWidget key={widget.id} widget={widget} />;
return <TopicWidget key={widget.id} props={widget} />;
case WIDGET_KIND.user:
return <UserWidget key={widget.id} widget={widget} />;
return <UserWidget key={widget.id} props={widget} />;
case WIDGET_KIND.thread:
return <ThreadWidget key={widget.id} widget={widget} />;
return <ThreadWidget key={widget.id} props={widget} />;
case WIDGET_KIND.article:
return <ArticleWidget key={widget.id} widget={widget} />;
return <ArticleWidget key={widget.id} props={widget} />;
case WIDGET_KIND.file:
return <FileWidget key={widget.id} widget={widget} />;
return <FileWidget key={widget.id} props={widget} />;
case WIDGET_KIND.hashtag:
return <HashtagWidget key={widget.id} widget={widget} />;
return <HashtagWidget key={widget.id} props={widget} />;
case WIDGET_KIND.group:
return <GroupWidget key={widget.id} widget={widget} />;
return <GroupWidget key={widget.id} props={widget} />;
case WIDGET_KIND.trendingNotes:
return <TrendingNotesWidget key={widget.id} widget={widget} />;
return <TrendingNotesWidget key={widget.id} props={widget} />;
case WIDGET_KIND.trendingAccounts:
return <TrendingAccountsWidget key={widget.id} widget={widget} />;
return <TrendingAccountsWidget key={widget.id} props={widget} />;
case WIDGET_KIND.list:
return <WidgetList key={widget.id} widget={widget} />;
return <WidgetList key={widget.id} props={widget} />;
default:
return <NewsfeedWidget key={widget.id} />;
}

View File

@@ -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 (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="h-4 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
if (isError) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
Failed to fetch event
</div>
</div>
);
}
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-200 dark:bg-neutral-800"></div>
<div className="break-p mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{data.content}
</div>
</div>
<NoteChildUser pubkey={data.pubkey} subtext={isRoot ? 'posted' : 'replied'} />
</div>
);
}

View File

@@ -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 (
<>
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black object-cover dark:bg-white"
/>
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">{fallbackName} </div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
</div>
</div>
</>
);
}
return (
<>
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">
{user?.display_name || user?.name || user?.displayName || fallbackName}{' '}
</div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,11 @@
import { useRichContent } from '@utils/hooks/useRichContent';
export function NoteContent({ content }: { content: string }) {
const { parsedContent } = useRichContent(content);
return (
<div className="break-p select-text whitespace-pre-line leading-normal">
{parsedContent}
</div>
);
}

View File

@@ -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,
};

View File

@@ -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 (
<Note.Root>
<Note.User pubkey={event.pubkey} time={event.created_at} />
{thread ? (
<div className="w-full px-3">
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? (
<Note.Child eventId={thread.rootEventId} isRoot />
) : null}
{thread.replyEventId ? <Note.Child eventId={thread.replyEventId} /> : null}
<button
type="button"
className="self-start text-blue-500 hover:text-blue-600"
>
Show full thread
</button>
</div>
</div>
) : null}
<Note.Content content={event.content} />
<div className="flex h-14 items-center justify-between px-3">
<div />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={event.id} rootEventId={thread?.rootEventId} />
<Note.Reaction event={event} />
<Note.Repost event={event} />
<Note.Zap event={event} />
</div>
</div>
</Note.Root>
);
}

View File

@@ -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<string | null>(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 (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type="button"
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
{reaction ? (
<img src={getReactionImage(reaction)} alt={reaction} className="h-5 w-5" />
) : (
<ReactionIcon className="h-5 w-5 group-hover:text-blue-500" />
)}
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="select-none rounded-md bg-neutral-200 px-1 py-1 text-sm will-change-[transform,opacity] data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800"
sideOffset={0}
side="top"
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => react('👏')}
className="inline-flex h-8 w-8 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img src="/clapping_hands.png" alt="Clapping Hands" className="h-6 w-6" />
</button>
<button
type="button"
onClick={() => react('🤪')}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img
src="/face_with_tongue.png"
alt="Face with Tongue"
className="h-6 w-6"
/>
</button>
<button
type="button"
onClick={() => react('😮')}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img
src="/face_with_open_mouth.png"
alt="Face with Open Mouth"
className="h-6 w-6"
/>
</button>
<button
type="button"
onClick={() => react('😢')}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img src="/crying_face.png" alt="Crying Face" className="h-6 w-6" />
</button>
<button
type="button"
onClick={() => react('🤡')}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img src="/clown_face.png" alt="Clown Face" className="h-6 w-6" />
</button>
</div>
<Popover.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View File

@@ -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 (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() =>
navigate({
pathname: '/new/',
search: createSearchParams({
replyTo: eventId,
rootReplyTo: rootEventId,
}).toString(),
})
}
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<ReplyIcon className="h-5 w-5 group-hover:text-blue-500" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800 dark:text-neutral-100">
Quick reply
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -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 (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={submit}
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<RepostIcon
className={twMerge(
'h-5 w-5 group-hover:text-blue-600',
isRepost ? 'text-blue-500' : ''
)}
/>
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800 dark:text-neutral-100">
Repost
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,18 @@
import { ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
export function NoteRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={twMerge('h-min w-full p-3', className)}>
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 dark:bg-neutral-950">
{children}
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex gap-3">
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<div className="h-6 w-6 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className="flex gap-2">
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-neutral-900 dark:text-neutral-100/80">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</h5>
<span className="text-blue-500">reposted</span>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center gap-3">
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Root>
<div className="h-6 flex-1">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{fallbackName}
</div>
</div>
</div>
);
}
return (
<div className="flex items-center gap-3">
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-9 w-9 rounded-lg bg-white object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex h-6 flex-1 items-start gap-2">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</div>
<div className="ml-auto inline-flex items-center gap-3">
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
</div>
</div>
</div>
);
}

View File

@@ -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<string>(null);
const [amount, setAmount] = useState<string>('21');
const [zapMessage, setZapMessage] = useState<string>('');
const [invoice, setInvoice] = useState<null | string>(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 (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<button
type="button"
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<ZapIcon className="h-5 w-5 group-hover:text-blue-500" />
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-xl rounded-xl bg-white dark:bg-black">
<div className="inline-flex w-full shrink-0 items-center justify-between px-5 py-3">
<div className="w-6" />
<Dialog.Title className="text-center font-semibold">
Send tip to{' '}
{user?.name || user?.displayName || displayNpub(event.pubkey, 16)}
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900">
<CancelIcon className="h-4 w-4" />
</Dialog.Close>
</div>
<div className="overflow-y-auto overflow-x-hidden px-5 pb-5">
{!invoice ? (
<>
<div className="relative flex h-40 flex-col">
<div className="inline-flex h-full flex-1 items-center justify-center gap-1">
<CurrencyInput
placeholder="0"
defaultValue={'21'}
value={amount}
decimalsLimit={2}
min={0} // 0 sats
max={10000} // 1M sats
maxLength={10000} // 1M sats
onValueChange={(value) => 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"
/>
<span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-600 dark:text-neutral-400">
sats
</span>
</div>
<div className="inline-flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setAmount('69')}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
69 sats
</button>
<button
type="button"
onClick={() => setAmount('100')}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
100 sats
</button>
<button
type="button"
onClick={() => setAmount('200')}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
200 sats
</button>
<button
type="button"
onClick={() => setAmount('500')}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
500 sats
</button>
<button
type="button"
onClick={() => setAmount('1000')}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
1K sats
</button>
</div>
</div>
<div className="mt-4 flex w-full flex-col gap-2">
<input
name="zapMessage"
value={zapMessage}
onChange={(e) => 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"
/>
<div className="flex flex-col gap-2">
{walletConnectURL ? (
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
>
{isCompleted ? (
<p className="leading-tight">Successfully zapped</p>
) : isLoading ? (
<span className="flex flex-col">
<p className="leading-tight">Waiting for approval</p>
<p className="text-xs leading-tight text-neutral-100">
Go to your wallet and approve payment request
</p>
</span>
) : (
<span className="flex flex-col">
<p className="leading-tight">Send zap</p>
<p className="text-xs leading-tight text-neutral-100">
You&apos;re using nostr wallet connect
</p>
</span>
)}
</button>
) : (
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
>
Create Lightning invoice
</button>
)}
</div>
</div>
</>
) : (
<div className="mt-3 flex flex-col items-center justify-center gap-4">
<div className="rounded-md bg-neutral-100 p-3 dark:bg-neutral-900">
<QRCodeSVG value={invoice} size={256} />
</div>
<div className="flex flex-col items-center gap-1">
<h3 className="text-lg font-medium">Scan to zap</h3>
<span className="text-center text-sm text-neutral-600 dark:text-neutral-400">
You must use Bitcoin wallet which support Lightning
<br />
such as: Blue Wallet, Bitkit, Phoenix,...
</span>
</div>
</div>
)}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -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';

View File

@@ -1,24 +1,20 @@
import { SVGProps } from 'react';
export function HorizontalDotsIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
) {
export function HorizontalDotsIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 13a1 1 0 100-2 1 1 0 000 2zM20 13a1 1 0 100-2 1 1 0 000 2zM4 13a1 1 0 100-2 1 1 0 000 2z"
></path>
<path d="M6 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" />
<path d="M13 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" />
<path d="M20 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" />
</svg>
);
}

View File

@@ -1,25 +1,18 @@
import { SVGProps } from 'react';
export function ReactionIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
export function ReactionIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path
fill="currentColor"
d="M10.499 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5 1.25.672 1.25 1.5zM15.999 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5 1.25.672 1.25 1.5z"
></path>
<path
fill="currentColor"
fillRule="evenodd"
d="M18.999 1a1 1 0 011 1v2h2a1 1 0 110 2h-2v2a1 1 0 11-2 0V6h-2a1 1 0 010-2h2V2a1 1 0 011-1zm-7.006 1.945a1 1 0 01-.884 1.104 8.001 8.001 0 108.842 8.841 1 1 0 011.988.22C21.386 18.11 17.148 22 12 22 6.477 22 2 17.523 2 12c0-5.147 3.888-9.385 8.889-9.939a1 1 0 011.104.884zm-3.53 11.176a1 1 0 011.415 0 3 3 0 004.242 0 1 1 0 011.415 1.415 5 5 0 01-7.072 0 1 1 0 010-1.415z"
clipRule="evenodd"
></path>
<path d="M8.43 14.5A4.985 4.985 0 0 0 12 16a4.985 4.985 0 0 0 3.57-1.5m-3.42 6.8a9.15 9.15 0 1 1 0-18.3 9.15 9.15 0 0 1 0 18.3Z" />
</svg>
);
}

View File

@@ -1,29 +1,18 @@
import { SVGProps } from 'react';
export function ReplyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
export function ReplyIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 21a9 9 0 10-9-9c0 1.354.3 2.639.835 3.791.102.219.133.465.076.7l-.778 3.191a1 1 0 001.191 1.213l3.33-.752c.224-.05.458-.02.667.073A8.969 8.969 0 0012 21z"
></path>
<path
fill="currentColor"
stroke="currentColor"
strokeLinecap="square"
strokeWidth="0.75"
d="M6.625 12a.875.875 0 101.75 0 .875.875 0 00-1.75 0zm4.5 0a.875.875 0 101.75 0 .875.875 0 00-1.75 0zm4.5 0a.875.875 0 101.75 0 .875.875 0 00-1.75 0z"
></path>
<path d="M8 10h8m-8 4h4m9-2a9 9 0 0 1-10.272 8.91c-1.203-.17-1.805-.255-1.964-.267-.257-.02-.165-.016-.423-.014-.159 0-.34.014-.702.04l-2.153.153c-.857.062-1.286.092-1.607-.06a1.35 1.35 0 0 1-.641-.641c-.152-.32-.122-.75-.06-1.607l.153-2.153c.026-.362.04-.543.04-.702.002-.258.006-.166-.014-.423-.012-.159-.098-.76-.268-1.964A9 9 0 1 1 21 12Z" />
</svg>
);
}

View File

@@ -1,19 +1,18 @@
import { SVGProps } from 'react';
export function RepostIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
export function RepostIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path
fill="currentColor"
d="M17.957 2.293a1 1 0 10-1.414 1.414L17.836 5H6a3 3 0 00-3 3v3a1 1 0 102 0V8a1 1 0 011-1h11.836l-1.293 1.293a1 1 0 001.414 1.414l2.47-2.47a1.75 1.75 0 000-2.474l-2.47-2.47zM20 12a1 1 0 011 1v3a3 3 0 01-3 3H6.164l1.293 1.293a1 1 0 11-1.414 1.414l-2.47-2.47a1.75 1.75 0 010-2.474l2.47-2.47a1 1 0 011.414 1.414L6.164 17H18a1 1 0 001-1v-3a1 1 0 011-1z"
></path>
<path d="M12 2a15.267 15.267 0 0 1 2.92 2.777c.054.066.08.145.08.225M12 8a15.266 15.266 0 0 0 2.92-2.777.356.356 0 0 0 .08-.221M12 16a15.264 15.264 0 0 0-2.92 2.777.356.356 0 0 0-.08.221M12 22a15.264 15.264 0 0 1-2.92-2.777.355.355 0 0 1-.08-.225m6-13.996C14.7 5 14.368 5 14 5h-4c-1.861 0-2.792 0-3.545.245a5 5 0 0 0-3.21 3.21C3 9.208 3 10.139 3 12s0 2.792.245 3.545A5 5 0 0 0 5 18m4 .998c.3.002.632.002 1 .002h4c1.861 0 2.792 0 3.545-.245a5 5 0 0 0 3.21-3.21C21 14.792 21 13.861 21 12s0-2.792-.245-3.545A5 5 0 0 0 19 6" />
</svg>
);
}

View File

@@ -1,22 +1,18 @@
import { SVGProps } from 'react';
export function ZapIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
export function ZapIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4.116 12.276l4.5-9A.5.5 0 019.063 3h8.058a.5.5 0 01.429.757l-2.091 3.486a.5.5 0 00.428.757h4.804a.5.5 0 01.332.873L7.381 21.023c-.38.34-.965-.042-.808-.527l2.219-6.842A.5.5 0 008.316 13H4.563a.5.5 0 01-.447-.724z"
></path>
<path d="m6.054 8.38 5.756-4.513c1.638-1.284 2.457-1.926 3.018-1.863.485.055.912.366 1.136.828.26.533-.003 1.58-.53 3.673l-.44 1.753c-.211.838-.316 1.257-.244 1.62.064.32.221.611.45.83.259.249.652.361 1.438.585l.51.146c1.512.432 2.268.648 2.57 1.087.264.382.348.872.23 1.328-.137.526-.772 1.014-2.041 1.99l-5.652 4.342c-1.628 1.25-2.442 1.876-3 1.81a1.458 1.458 0 0 1-1.129-.831c-.257-.532.003-1.565.522-3.63l.394-1.568c.211-.838.316-1.257.244-1.621a1.575 1.575 0 0 0-.45-.83c-.259-.248-.652-.36-1.438-.585l-.568-.162c-1.496-.427-2.244-.64-2.546-1.077a1.631 1.631 0 0 1-.234-1.322c.132-.524.756-1.013 2.004-1.99Z" />
</svg>
);
}

View File

@@ -16,7 +16,7 @@ export function TextNote({ event, className }: { event: NDKEvent; className?: st
const thread = ark.getEventThread({ tags: event.tags });
return (
<div className={twMerge('mb-3 h-min w-full px-3', className)}>
<div className={twMerge('my-3 h-min w-full px-3', className)}>
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
{thread ? (