refactor: everything

This commit is contained in:
2023-12-24 19:14:46 +07:00
parent 9591d8626d
commit a6da07cd3f
127 changed files with 1447 additions and 4874 deletions

View File

@@ -84,6 +84,7 @@
"sonner": "^1.2.4", "sonner": "^1.2.4",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.8", "tiptap-markdown": "^0.8.8",
"use-context-selector": "^1.4.1",
"use-react-workers": "^0.3.0", "use-react-workers": "^0.3.0",
"virtua": "^0.18.0", "virtua": "^0.18.0",
"zustand": "^4.4.7" "zustand": "^4.4.7"

21
pnpm-lock.yaml generated
View File

@@ -203,6 +203,9 @@ dependencies:
tiptap-markdown: tiptap-markdown:
specifier: ^0.8.8 specifier: ^0.8.8
version: 0.8.8(@tiptap/core@2.1.13) version: 0.8.8(@tiptap/core@2.1.13)
use-context-selector:
specifier: ^1.4.1
version: 1.4.1(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)
use-react-workers: use-react-workers:
specifier: ^0.3.0 specifier: ^0.3.0
version: 0.3.0(react@18.2.0) version: 0.3.0(react@18.2.0)
@@ -5855,6 +5858,24 @@ packages:
tslib: 2.6.2 tslib: 2.6.2
dev: false dev: false
/use-context-selector@1.4.1(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0):
resolution: {integrity: sha512-Io2ArvcRO+6MWIhkdfMFt+WKQX+Vb++W8DS2l03z/Vw/rz3BclKpM0ynr4LYGyU85Eke+Yx5oIhTY++QR0ZDoA==}
peerDependencies:
react: '>=16.8.0'
react-dom: '*'
react-native: '*'
scheduler: '>=0.19.0'
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
scheduler: 0.23.0
dev: false
/use-react-workers@0.3.0(react@18.2.0): /use-react-workers@0.3.0(react@18.2.0):
resolution: {integrity: sha512-CQv/b5lnccR5G1HzrCFbkyeCcKD+TEYFm20veNd+huNSRBM0OXxdvcxAU7vUp3rj8/bHx7WE/rYvCHRyTfJOpQ==} resolution: {integrity: sha512-CQv/b5lnccR5G1HzrCFbkyeCcKD+TEYFm20veNd+huNSRBM0OXxdvcxAU7vUp3rj8/bHx7WE/rYvCHRyTfJOpQ==}
peerDependencies: peerDependencies:

View File

@@ -1,7 +1,7 @@
import { fetch } from '@tauri-apps/plugin-http'; import { fetch } from '@tauri-apps/plugin-http';
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom'; import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
import { ErrorScreen } from '@app/error'; import { ErrorScreen } from '@app/error';
import { useArk } from '@libs/ark'; import { useStorage } from '@libs/ark';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { AppLayout } from '@shared/layouts/app'; import { AppLayout } from '@shared/layouts/app';
import { AuthLayout } from '@shared/layouts/auth'; import { AuthLayout } from '@shared/layouts/auth';
@@ -10,18 +10,18 @@ import { HomeLayout } from '@shared/layouts/home';
import { SettingsLayout } from '@shared/layouts/settings'; import { SettingsLayout } from '@shared/layouts/settings';
export default function App() { export default function App() {
const ark = useArk(); const storage = useStorage();
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
element: <AppLayout platform={ark.platform} />, element: <AppLayout platform={storage.platform} />,
children: [ children: [
{ {
path: '/', path: '/',
element: <HomeLayout />, element: <HomeLayout />,
errorElement: <ErrorScreen />, errorElement: <ErrorScreen />,
loader: async () => { loader: async () => {
if (!ark.account) return redirect('auth/welcome'); if (!storage.account) return redirect('auth/welcome');
return null; return null;
}, },
children: [ children: [
@@ -168,7 +168,7 @@ export default function App() {
{ {
index: true, index: true,
loader: () => { loader: () => {
const depot = ark.checkDepot(); const depot = storage.checkDepot();
if (!depot) return redirect('/depot/onboarding/'); if (!depot) return redirect('/depot/onboarding/');
return null; return null;
}, },
@@ -190,7 +190,7 @@ export default function App() {
}, },
{ {
path: 'auth', path: 'auth',
element: <AuthLayout platform={ark.platform} />, element: <AuthLayout platform={storage.platform} />,
errorElement: <ErrorScreen />, errorElement: <ErrorScreen />,
children: [ children: [
{ {

View File

@@ -1,4 +1,3 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import * as Accordion from '@radix-ui/react-accordion'; import * as Accordion from '@radix-ui/react-accordion';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
@@ -35,6 +34,8 @@ const LUME_USERS = ['npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6x
export function FollowScreen() { export function FollowScreen() {
const ark = useArk(); const ark = useArk();
const navigate = useNavigate();
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['trending-profiles-widget'], queryKey: ['trending-profiles-widget'],
queryFn: async () => { queryFn: async () => {
@@ -49,8 +50,6 @@ export function FollowScreen() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState<string[]>([]); const [follows, setFollows] = useState<string[]>([]);
const navigate = useNavigate();
// toggle follow state // toggle follow state
const toggleFollow = (pubkey: string) => { const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey) const arr = follows.includes(pubkey)
@@ -64,8 +63,7 @@ export function FollowScreen() {
setLoading(true); setLoading(true);
if (!follows.length) return navigate('/auth/finish'); if (!follows.length) return navigate('/auth/finish');
const publish = await ark.createEvent({ const publish = await ark.newContactList({
kind: NDKKind.Contacts,
tags: follows.map((item) => { tags: follows.map((item) => {
if (item.startsWith('npub1')) return ['p', nip19.decode(item).data as string]; if (item.startsWith('npub1')) return ['p', nip19.decode(item).data as string];
return ['p', item]; return ['p', item];
@@ -73,11 +71,6 @@ export function FollowScreen() {
}); });
if (publish) { if (publish) {
ark.account.contacts = follows.map((item) => {
if (item.startsWith('npub1')) return nip19.decode(item).data as string;
return item;
});
setLoading(false); setLoading(false);
return navigate('/auth/finish'); return navigate('/auth/finish');
} }

View File

@@ -7,7 +7,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { useArk } from '@libs/ark'; import { useArk, useStorage } from '@libs/ark';
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons'; import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user'; import { User } from '@shared/user';
@@ -20,6 +20,7 @@ export function ImportAccountScreen() {
const [savedPrivkey, setSavedPrivkey] = useState(false); const [savedPrivkey, setSavedPrivkey] = useState(false);
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const navigate = useNavigate(); const navigate = useNavigate();
const submitNpub = async () => { const submitNpub = async () => {
@@ -42,8 +43,8 @@ export function ImportAccountScreen() {
const pubkey = nip19.decode(npub.split('#')[0]).data as string; const pubkey = nip19.decode(npub.split('#')[0]).data as string;
const localSigner = NDKPrivateKeySigner.generate(); const localSigner = NDKPrivateKeySigner.generate();
await ark.createSetting('nsecbunker', '1'); await storage.createSetting('nsecbunker', '1');
await ark.createPrivkey(`${npub}-nsecbunker`, localSigner.privateKey); await storage.createPrivkey(`${npub}-nsecbunker`, localSigner.privateKey);
// open nsecbunker web app in default browser // open nsecbunker web app in default browser
await open('https://app.nsecbunker.com/keys'); await open('https://app.nsecbunker.com/keys');
@@ -74,7 +75,7 @@ export function ImportAccountScreen() {
setLoading(true); setLoading(true);
// add account to db // add account to db
await ark.createAccount({ id: npub, pubkey }); await storage.createAccount({ id: npub, pubkey });
// get account contacts // get account contacts
await ark.getUserContacts({ pubkey }); await ark.getUserContacts({ pubkey });
@@ -99,7 +100,7 @@ export function ImportAccountScreen() {
if (nsec.length > 50 && nsec.startsWith('nsec1')) { if (nsec.length > 50 && nsec.startsWith('nsec1')) {
try { try {
const privkey = nip19.decode(nsec).data as string; const privkey = nip19.decode(nsec).data as string;
await ark.createPrivkey(pubkey, privkey); await storage.createPrivkey(pubkey, privkey);
ark.updateNostrSigner({ signer: new NDKPrivateKeySigner(privkey) }); ark.updateNostrSigner({ signer: new NDKPrivateKeySigner(privkey) });
setSavedPrivkey(true); setSavedPrivkey(true);
@@ -279,9 +280,9 @@ export function ImportAccountScreen() {
<p className="text-sm"> <p className="text-sm">
Lume will put your private key to{' '} Lume will put your private key to{' '}
<b> <b>
{ark.platform === 'macos' {storage.platform === 'macos'
? 'Apple Keychain (macOS)' ? 'Apple Keychain (macOS)'
: ark.platform === 'windows' : storage.platform === 'windows'
? 'Credential Manager (Windows)' ? 'Credential Manager (Windows)'
: 'Secret Service (Linux)'} : 'Secret Service (Linux)'}
</b> </b>

View File

@@ -2,11 +2,11 @@ import * as Switch from '@radix-ui/react-switch';
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification'; import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useArk } from '@libs/ark'; import { useStorage } from '@libs/ark';
import { InfoIcon } from '@shared/icons'; import { InfoIcon } from '@shared/icons';
export function OnboardingScreen() { export function OnboardingScreen() {
const ark = useArk(); const storage = useStorage();
const navigate = useNavigate(); const navigate = useNavigate();
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
@@ -16,19 +16,18 @@ export function OnboardingScreen() {
}); });
const next = () => { const next = () => {
if (!ark.account.contacts.length) return navigate('/auth/follow'); if (!storage.account.contacts.length) return navigate('/auth/follow');
return navigate('/auth/finish'); return navigate('/auth/finish');
}; };
const toggleOutbox = async () => { const toggleOutbox = async () => {
await ark.createSetting('outbox', String(+!settings.outbox)); await storage.createSetting('outbox', String(+!settings.outbox));
// update state // update state
setSettings((prev) => ({ ...prev, outbox: !settings.outbox })); setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
}; };
const toggleAutoupdate = async () => { const toggleAutoupdate = async () => {
await ark.createSetting('autoupdate', String(+!settings.autoupdate)); await storage.createSetting('autoupdate', String(+!settings.autoupdate));
ark.settings.autoupdate = !settings.autoupdate;
// update state // update state
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate })); setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
}; };
@@ -44,7 +43,7 @@ export function OnboardingScreen() {
const permissionGranted = await isPermissionGranted(); const permissionGranted = await isPermissionGranted();
setSettings((prev) => ({ ...prev, notification: permissionGranted })); setSettings((prev) => ({ ...prev, notification: permissionGranted }));
const data = await ark.getAllSettings(); const data = await storage.getAllSettings();
if (!data) return; if (!data) return;
data.forEach((item) => { data.forEach((item) => {

View File

@@ -1,7 +1,7 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { TextNote } from '@libs/ark';
import { EditIcon, ReactionIcon, ReplyIcon, RepostIcon, ZapIcon } from '@shared/icons'; import { EditIcon, ReactionIcon, ReplyIcon, RepostIcon, ZapIcon } from '@shared/icons';
import { TextNote } from '@shared/notes';
export function TutorialNoteScreen() { export function TutorialNoteScreen() {
const exampleEvent = new NDKEvent(undefined, { const exampleEvent = new NDKEvent(undefined, {
@@ -32,7 +32,7 @@ export function TutorialNoteScreen() {
updated in real-time. updated in real-time.
</p> </p>
<p className="px-3 font-semibold">Here is one example:</p> <p className="px-3 font-semibold">Here is one example:</p>
<TextNote event={exampleEvent} className="pointer-events-none my-2" /> <TextNote event={exampleEvent} />
<p className="px-3 font-semibold">Here are how you can interact with a note:</p> <p className="px-3 font-semibold">Here are how you can interact with a note:</p>
<div className="flex flex-col gap-2 px-3"> <div className="flex flex-col gap-2 px-3">
<div className="inline-flex gap-3"> <div className="inline-flex gap-3">

View File

@@ -1,119 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { VList, VListHandle } from 'virtua';
import { ChatForm } from '@app/chats/components/chatForm';
import { ChatMessage } from '@app/chats/components/message';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
export function ChatScreen() {
const ark = useArk();
const { pubkey } = useParams();
const { status, data } = useQuery({
queryKey: ['nip04-dm', pubkey],
queryFn: async () => {
return await ark.getAllMessagesByPubkey({ pubkey });
},
refetchOnWindowFocus: false,
});
const queryClient = useQueryClient();
const listRef = useRef<VListHandle>(null);
const newMessage = useMutation({
mutationFn: async (event: NDKEvent) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['nip04-dm', pubkey] });
// Snapshot the previous value
const prevMessages = queryClient.getQueryData(['nip04-dm', pubkey]);
// Optimistically update to the new value
queryClient.setQueryData(['nip04-dm', pubkey], (prev: NDKEvent[]) => [
...prev,
event,
]);
// Return a context object with the snapshotted value
return { prevMessages };
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['nip04-dm', pubkey] });
},
});
const renderItem = useCallback(
(message: NDKEvent) => {
return (
<ChatMessage
key={message.id}
message={message}
isSelf={message.pubkey === ark.account.pubkey}
/>
);
},
[data]
);
useEffect(() => {
if (data && data.length > 0) listRef.current?.scrollToIndex(data.length);
}, [data]);
useEffect(() => {
const sub = ark.subscribe({
filter: {
kinds: [4],
authors: [ark.account.pubkey],
'#p': [pubkey],
since: Math.floor(Date.now() / 1000),
},
closeOnEose: false,
cb: (event) => newMessage.mutate(event),
});
return () => {
sub.stop();
};
}, [pubkey]);
return (
<div className="h-full w-full p-3">
<div className="rounded-lg bg-neutral-100 backdrop-blur-xl dark:bg-neutral-900">
<div className="flex h-full flex-col justify-between overflow-hidden">
<div className="flex h-16 shrink-0 items-center border-b border-neutral-200 px-3 dark:border-neutral-800">
<User pubkey={pubkey} variant="simple" />
</div>
<div className="h-full w-full flex-1 px-3 py-3">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-1.5">
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-300">
Loading messages
</p>
</div>
</div>
) : data.length === 0 ? (
<div className="absolute left-1/2 top-1/2 flex w-full -translate-x-1/2 -translate-y-1/2 transform flex-col gap-1 text-center">
<h3 className="mb-2 text-4xl">🙌</h3>
<p className="leading-none text-neutral-500 dark:text-neutral-300">
You two didn&apos;t talk yet, let&apos;s send first message
</p>
</div>
) : (
<VList ref={listRef} className="h-full scrollbar-none" shift={true} reverse>
{data.map((message) => renderItem(message))}
</VList>
)}
</div>
<div className="shrink-0 rounded-b-lg border-t border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800">
<ChatForm receiverPubkey={pubkey} />
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,57 +0,0 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { MediaUploader } from '@app/chats/components/mediaUploader';
import { useArk } from '@libs/ark';
import { EnterIcon } from '@shared/icons';
export function ChatForm({ receiverPubkey }: { receiverPubkey: string }) {
const ark = useArk();
const [value, setValue] = useState('');
const submit = async () => {
try {
const publish = await ark.nip04Encrypt({ content: value, pubkey: receiverPubkey });
if (publish) setValue('');
} catch (e) {
toast.error(e);
}
};
const handleEnterPress = (e: {
key: string;
shiftKey: KeyboardEvent['shiftKey'];
preventDefault: () => void;
}) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
return (
<div className="flex items-center gap-2">
<MediaUploader setState={setValue} />
<div className="flex w-full items-center justify-between rounded-full bg-neutral-300 px-3 dark:bg-neutral-700">
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="Message..."
className="h-10 flex-1 resize-none border-none bg-transparent px-3 text-neutral-900 placeholder:text-neutral-600 focus:border-none focus:shadow-none focus:outline-none focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-300"
/>
<button
type="button"
onClick={submit}
className="inline-flex shrink-0 items-center gap-1.5 text-sm font-medium text-neutral-600 dark:text-neutral-300"
>
<EnterIcon className="h-5 w-5" />
Send
</button>
</div>
</div>
);
}

View File

@@ -1,75 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { memo } from 'react';
import { NavLink } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
import { displayNpub, formatCreatedAt } from '@utils/formater';
import { useProfile } from '@utils/hooks/useProfile';
export const ChatListItem = memo(function ChatListItem({ event }: { event: NDKEvent }) {
const { isLoading, user } = useProfile(event.pubkey);
const decryptedContent = useDecryptMessage(event);
const createdAt = formatCreatedAt(event.created_at, true);
const svgURI =
'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(event.pubkey, 90, 50));
if (isLoading) {
return (
<div className="flex items-center gap-2.5 rounded-md px-3">
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-400 dark:bg-neutral-600" />
<div className="flex w-full flex-col">
<div className="h-2.5 w-1/2 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
<div className="h-2.5 w-full animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
</div>
</div>
);
}
return (
<NavLink
to={`/chats/chat/${event.pubkey}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'flex items-center gap-2.5 px-3 py-1.5 hover:bg-neutral-200 dark:hover:bg-neutral-800',
isActive
? 'bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'
: 'text-neutral-500 dark:text-neutral-300'
)
}
>
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={event.pubkey}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-10 w-10 rounded-lg"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={event.pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex w-full flex-col">
<div className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name ||
user?.display_name ||
user?.displayName ||
displayNpub(event.pubkey, 16)}
</div>
<div className="flex w-full items-center justify-between">
<div className="max-w-[10rem] truncate text-sm">{decryptedContent}</div>
<div className="text-sm">{createdAt}</div>
</div>
</div>
</NavLink>
);
});

View File

@@ -1,55 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { Dispatch, SetStateAction, useState } from 'react';
import { useArk } from '@libs/ark';
import { LoaderIcon, MediaIcon } from '@shared/icons';
export function MediaUploader({
setState,
}: {
setState: Dispatch<SetStateAction<string>>;
}) {
const ark = useArk();
const [loading, setLoading] = useState(false);
const uploadMedia = async () => {
setLoading(true);
const image = await ark.upload({
fileExts: ['mp4', 'mp3', 'webm', 'mkv', 'avi', 'mov'],
});
if (image) {
setState((prev: string) => `${prev}\n${image}`);
setLoading(false);
}
};
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => uploadMedia()}
className="group inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-neutral-300 text-neutral-600 hover:bg-neutral-400 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<MediaIcon className="h-4 w-4" />
)}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="-left-10 select-none rounded-md bg-black px-3.5 py-1.5 text-sm leading-none text-white 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"
sideOffset={5}
>
Upload media
<Tooltip.Arrow className="fill-black" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,24 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { twMerge } from 'tailwind-merge';
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
export function ChatMessage({ message, isSelf }: { message: NDKEvent; isSelf: boolean }) {
const decryptedContent = useDecryptMessage(message);
return (
<div
className={twMerge(
'my-2 w-max max-w-[400px] rounded-t-xl px-3 py-3',
isSelf
? 'ml-auto rounded-l-xl bg-blue-500 text-white'
: 'rounded-r-xl bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'
)}
>
{!decryptedContent ? (
<p>Decrypting...</p>
) : (
<p className="select-text whitespace-pre-line break-all">{decryptedContent}</p>
)}
</div>
);
}

View File

@@ -1,23 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useArk } from '@libs/ark';
export function useDecryptMessage(event: NDKEvent) {
const ark = useArk();
const [content, setContent] = useState(event.content);
useEffect(() => {
async function decryptContent() {
try {
const message = await ark.nip04Decrypt({ event });
setContent(message);
} catch (e) {
console.error(e);
}
}
decryptContent();
}, []);
return content;
}

View File

@@ -1,66 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { Outlet } from 'react-router-dom';
import { ChatListItem } from '@app/chats/components/chatListItem';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
export function ChatsScreen() {
const ark = useArk();
const { status, data } = useQuery({
queryKey: ['nip04-chats'],
queryFn: async () => {
return await ark.getAllChats();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
const renderItem = useCallback(
(event: NDKEvent) => {
return <ChatListItem key={event.id} event={event} />;
},
[data]
);
return (
<div className="grid h-full w-full grid-cols-3">
<div className="col-span-1 h-full overflow-y-auto border-r border-neutral-200 scrollbar-none dark:border-neutral-800">
<div
data-tauri-drag-region
className="flex h-11 w-full shrink-0 items-center border-b border-neutral-100 px-3 dark:border-neutral-900"
>
<h3 className="font-semibold text-neutral-950 dark:text-neutral-50">
All chats
</h3>
</div>
<div className="flex h-full flex-col gap-1">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center pb-16">
<div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
<h5 className="text-neutral-900 dark:text-neutral-100">
Loading messages...
</h5>
</div>
</div>
) : data.length < 1 ? (
<div className="flex h-full w-full items-center justify-center pb-16">
<div className="inline-flex flex-col items-center justify-center gap-2">
<h5 className="text-neutral-900 dark:text-neutral-100">No message</h5>
</div>
</div>
) : (
data.map((item) => renderItem(item))
)}
</div>
</div>
<div className="col-span-2">
<Outlet />
</div>
</div>
);
}

View File

@@ -1,12 +1,14 @@
import { NDKKind } from '@nostr-dev-kit/ndk'; import { NDKKind } from '@nostr-dev-kit/ndk';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useArk } from '@libs/ark'; import { useArk, useStorage } from '@libs/ark';
import { LoaderIcon, RunIcon } from '@shared/icons'; import { LoaderIcon, RunIcon } from '@shared/icons';
import { User } from '@shared/user'; import { User } from '@shared/user';
export function DepotContactCard() { export function DepotContactCard() {
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const [status, setStatus] = useState(false); const [status, setStatus] = useState(false);
const backupContact = async () => { const backupContact = async () => {
@@ -14,7 +16,7 @@ export function DepotContactCard() {
setStatus(true); setStatus(true);
const event = await ark.getEventByFilter({ const event = await ark.getEventByFilter({
filter: { authors: [ark.account.pubkey], kinds: [NDKKind.Contacts] }, filter: { authors: [storage.account.pubkey], kinds: [NDKKind.Contacts] },
}); });
// broadcast to depot // broadcast to depot
@@ -34,13 +36,13 @@ export function DepotContactCard() {
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900"> <div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800"> <div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
<div className="isolate flex -space-x-2"> <div className="isolate flex -space-x-2">
{ark.account.contacts {storage.account.contacts
?.slice(0, 8) ?.slice(0, 8)
.map((item) => <User key={item} pubkey={item} variant="ministacked" />)} .map((item) => <User key={item} pubkey={item} variant="ministacked" />)}
{ark.account.contacts?.length > 8 ? ( {storage.account.contacts?.length > 8 ? (
<div className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-300 text-neutral-900 ring-1 ring-white dark:bg-neutral-700 dark:text-neutral-100 dark:ring-black"> <div className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-300 text-neutral-900 ring-1 ring-white dark:bg-neutral-700 dark:text-neutral-100 dark:ring-black">
<span className="text-[8px] font-medium"> <span className="text-[8px] font-medium">
+{ark.account.contacts?.length - 8} +{storage.account.contacts?.length - 8}
</span> </span>
</div> </div>
) : null} ) : null}

View File

@@ -1,12 +1,14 @@
import { NDKKind } from '@nostr-dev-kit/ndk'; import { NDKKind } from '@nostr-dev-kit/ndk';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useArk } from '@libs/ark'; import { useArk, useStorage } from '@libs/ark';
import { LoaderIcon, RunIcon } from '@shared/icons'; import { LoaderIcon, RunIcon } from '@shared/icons';
import { User } from '@shared/user'; import { User } from '@shared/user';
export function DepotProfileCard() { export function DepotProfileCard() {
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const [status, setStatus] = useState(false); const [status, setStatus] = useState(false);
const backupProfile = async () => { const backupProfile = async () => {
@@ -14,7 +16,7 @@ export function DepotProfileCard() {
setStatus(true); setStatus(true);
const event = await ark.getEventByFilter({ const event = await ark.getEventByFilter({
filter: { authors: [ark.account.pubkey], kinds: [NDKKind.Metadata] }, filter: { authors: [storage.account.pubkey], kinds: [NDKKind.Metadata] },
}); });
// broadcast to depot // broadcast to depot
@@ -33,7 +35,7 @@ export function DepotProfileCard() {
return ( return (
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900"> <div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800"> <div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
<User pubkey={ark.account.pubkey} variant="simple" /> <User pubkey={storage.account.pubkey} variant="simple" />
</div> </div>
<div className="inline-flex shrink-0 items-center justify-between"> <div className="inline-flex shrink-0 items-center justify-between">
<div className="text-sm font-medium">Profile</div> <div className="text-sm font-medium">Profile</div>

View File

@@ -1,11 +1,12 @@
import { NDKKind } from '@nostr-dev-kit/ndk'; import { NDKKind } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useArk } from '@libs/ark'; import { useArk, useStorage } from '@libs/ark';
import { LoaderIcon, RunIcon } from '@shared/icons'; import { LoaderIcon, RunIcon } from '@shared/icons';
export function DepotRelaysCard() { export function DepotRelaysCard() {
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const [status, setStatus] = useState(false); const [status, setStatus] = useState(false);
const [relaySize, setRelaySize] = useState(0); const [relaySize, setRelaySize] = useState(0);
@@ -15,7 +16,7 @@ export function DepotRelaysCard() {
setStatus(true); setStatus(true);
const event = await ark.getEventByFilter({ const event = await ark.getEventByFilter({
filter: { authors: [ark.account.pubkey], kinds: [NDKKind.RelayList] }, filter: { authors: [storage.account.pubkey], kinds: [NDKKind.RelayList] },
}); });
// broadcast to depot // broadcast to depot
@@ -34,7 +35,7 @@ export function DepotRelaysCard() {
useEffect(() => { useEffect(() => {
async function loadRelays() { async function loadRelays() {
const event = await ark.getEventByFilter({ const event = await ark.getEventByFilter({
filter: { authors: [ark.account.pubkey], kinds: [NDKKind.RelayList] }, filter: { authors: [storage.account.pubkey], kinds: [NDKKind.RelayList] },
}); });
if (event) setRelaySize(event.tags.length); if (event) setRelaySize(event.tags.length);
} }

View File

@@ -8,11 +8,12 @@ import { DepotContactCard } from '@app/depot/components/contact';
import { DepotMembers } from '@app/depot/components/members'; import { DepotMembers } from '@app/depot/components/members';
import { DepotProfileCard } from '@app/depot/components/profile'; import { DepotProfileCard } from '@app/depot/components/profile';
import { DepotRelaysCard } from '@app/depot/components/relays'; import { DepotRelaysCard } from '@app/depot/components/relays';
import { useArk } from '@libs/ark'; import { useArk, useStorage } from '@libs/ark';
import { ChevronDownIcon, DepotIcon, GossipIcon } from '@shared/icons'; import { ChevronDownIcon, DepotIcon, GossipIcon } from '@shared/icons';
export function DepotScreen() { export function DepotScreen() {
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const [dataPath, setDataPath] = useState(''); const [dataPath, setDataPath] = useState('');
const [tunnelUrl, setTunnelUrl] = useState(''); const [tunnelUrl, setTunnelUrl] = useState('');
@@ -33,7 +34,7 @@ export function DepotScreen() {
if (!/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/.test(relayUrl.host)) return; if (!/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/.test(relayUrl.host)) return;
const relayEvent = await ark.getEventByFilter({ const relayEvent = await ark.getEventByFilter({
filter: { authors: [ark.account.pubkey], kinds: [NDKKind.RelayList] }, filter: { authors: [storage.account.pubkey], kinds: [NDKKind.RelayList] },
}); });
let publish: { id: string; seens: string[] }; let publish: { id: string; seens: string[] };
@@ -54,7 +55,7 @@ export function DepotScreen() {
}); });
if (publish) { if (publish) {
await ark.createSetting('tunnel_url', tunnelUrl); await storage.createSetting('tunnel_url', tunnelUrl);
toast.success('Update relay list successfully.'); toast.success('Update relay list successfully.');
setTunnelUrl(''); setTunnelUrl('');

View File

@@ -4,12 +4,13 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { parse, stringify } from 'smol-toml'; import { parse, stringify } from 'smol-toml';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useArk } from '@libs/ark'; import { useArk, useStorage } from '@libs/ark';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { delay } from '@utils/delay'; import { delay } from '@utils/delay';
export function DepotOnboardingScreen() { export function DepotOnboardingScreen() {
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -24,15 +25,15 @@ export function DepotOnboardingScreen() {
const parsedConfig = parse(config); const parsedConfig = parse(config);
// add current user to whitelist // add current user to whitelist
parsedConfig.authorization['pubkey_whitelist'].push(ark.account.pubkey); parsedConfig.authorization['pubkey_whitelist'].push(storage.account.pubkey);
// update new config // update new config
const newConfig = stringify(parsedConfig); const newConfig = stringify(parsedConfig);
await writeTextFile(defaultConfig, newConfig); await writeTextFile(defaultConfig, newConfig);
// launch depot // launch depot
await ark.launchDepot(); await storage.launchDepot();
await ark.createSetting('depot', '1'); await storage.createSetting('depot', '1');
await delay(2000); // delay 2s to make sure depot is running await delay(2000); // delay 2s to make sure depot is running
// default depot url: ws://localhost:6090 // default depot url: ws://localhost:6090

View File

@@ -3,7 +3,7 @@ import { message, save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs'; import { writeTextFile } from '@tauri-apps/plugin-fs';
import { relaunch } from '@tauri-apps/plugin-process'; import { relaunch } from '@tauri-apps/plugin-process';
import { useRouteError } from 'react-router-dom'; import { useRouteError } from 'react-router-dom';
import { useArk } from '@libs/ark'; import { useStorage } from '@libs/ark';
interface RouteError { interface RouteError {
statusText: string; statusText: string;
@@ -11,7 +11,7 @@ interface RouteError {
} }
export function ErrorScreen() { export function ErrorScreen() {
const ark = useArk(); const storage = useStorage();
const error = useRouteError() as RouteError; const error = useRouteError() as RouteError;
const restart = async () => { const restart = async () => {
@@ -25,18 +25,18 @@ export function ErrorScreen() {
const filePath = await save({ const filePath = await save({
defaultPath: downloadPath + '/' + fileName, defaultPath: downloadPath + '/' + fileName,
}); });
const nsec = await ark.loadPrivkey(ark.account.pubkey); const nsec = await storage.loadPrivkey(storage.account.pubkey);
if (filePath) { if (filePath) {
if (nsec) { if (nsec) {
await writeTextFile( await writeTextFile(
filePath, filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${ark.account.id}\nPrivate key: ${nsec}` `Nostr account, generated by Lume (lume.nu)\nPublic key: ${storage.account.id}\nPrivate key: ${nsec}`
); );
} else { } else {
await writeTextFile( await writeTextFile(
filePath, filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${ark.account.id}` `Nostr account, generated by Lume (lume.nu)\nPublic key: ${storage.account.id}`
); );
} }
} // else { user cancel action } } // else { user cancel action }

View File

@@ -0,0 +1,2 @@
export * from './newsfeed';
export * from './notification';

View File

@@ -2,21 +2,16 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { VList, VListHandle } from 'virtua'; import { VList, VListHandle } from 'virtua';
import { Widget, useArk } from '@libs/ark'; import { RepostNote, TextNote, Widget, useArk, useStorage } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon, TimelineIcon } from '@shared/icons'; import { ArrowRightCircleIcon, LoaderIcon, TimelineIcon } from '@shared/icons';
import {
MemoizedRepost,
MemoizedTextNote,
NoteSkeleton,
UnknownNote,
} from '@shared/notes';
import { FETCH_LIMIT } from '@utils/constants'; import { FETCH_LIMIT } from '@utils/constants';
export function NewsfeedWidget() { export function NewsfeedWidget() {
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const ref = useRef<VListHandle>(); const ref = useRef<VListHandle>();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ['newsfeed'], queryKey: ['newsfeed'],
initialPageParam: 0, initialPageParam: 0,
@@ -30,9 +25,9 @@ export function NewsfeedWidget() {
const events = await ark.getInfiniteEvents({ const events = await ark.getInfiniteEvents({
filter: { filter: {
kinds: [NDKKind.Text, NDKKind.Repost], kinds: [NDKKind.Text, NDKKind.Repost],
authors: !ark.account.contacts.length authors: !storage.account.contacts.length
? [ark.account.pubkey] ? [storage.account.pubkey]
: ark.account.contacts, : storage.account.contacts,
}, },
limit: FETCH_LIMIT, limit: FETCH_LIMIT,
pageParam, pageParam,
@@ -57,11 +52,11 @@ export function NewsfeedWidget() {
const renderItem = (event: NDKEvent) => { const renderItem = (event: NDKEvent) => {
switch (event.kind) { switch (event.kind) {
case NDKKind.Text: case NDKKind.Text:
return <MemoizedTextNote key={event.id} event={event} />; return <TextNote key={event.id} event={event} />;
case NDKKind.Repost: case NDKKind.Repost:
return <MemoizedRepost key={event.id} event={event} />; return <RepostNote key={event.id} event={event} />;
default: default:
return <UnknownNote key={event.id} event={event} />; return <TextNote key={event.id} event={event} />;
} }
}; };
@@ -75,11 +70,10 @@ export function NewsfeedWidget() {
/> />
<Widget.Content> <Widget.Content>
<VList ref={ref} overscan={2} className="flex-1"> <VList ref={ref} overscan={2} className="flex-1">
{status === 'pending' ? ( {isLoading ? (
<div className="px-3 py-1.5"> <div className="inline-flex h-16 items-center justify-center gap-2 px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900"> <LoaderIcon className="size-5" />
<NoteSkeleton /> Loading
</div>
</div> </div>
) : ( ) : (
allEvents.map((item) => renderItem(item)) allEvents.map((item) => renderItem(item))

View File

@@ -2,14 +2,14 @@ import { NDKEvent, NDKKind, NDKSubscription } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { VList } from 'virtua'; import { VList } from 'virtua';
import { Widget, useArk } from '@libs/ark'; import { NoteSkeleton, TextNote, Widget, useArk, useStorage } from '@libs/ark';
import { AnnouncementIcon, ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { AnnouncementIcon, ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { MemoizedNotifyNote, NoteSkeleton } from '@shared/notes';
import { FETCH_LIMIT } from '@utils/constants'; import { FETCH_LIMIT } from '@utils/constants';
import { sendNativeNotification } from '@utils/notification'; import { sendNativeNotification } from '@utils/notification';
export function NotificationWidget() { export function NotificationWidget() {
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
@@ -26,7 +26,7 @@ export function NotificationWidget() {
const events = await ark.getInfiniteEvents({ const events = await ark.getInfiniteEvents({
filter: { filter: {
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap], kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
'#p': [ark.account.pubkey], '#p': [storage.account.pubkey],
}, },
limit: FETCH_LIMIT, limit: FETCH_LIMIT,
pageParam, pageParam,
@@ -52,17 +52,17 @@ export function NotificationWidget() {
); );
const renderEvent = (event: NDKEvent) => { const renderEvent = (event: NDKEvent) => {
if (event.pubkey === ark.account.pubkey) return null; if (event.pubkey === storage.account.pubkey) return null;
return <MemoizedNotifyNote key={event.id} event={event} />; return <TextNote key={event.id} event={event} />;
}; };
useEffect(() => { useEffect(() => {
let sub: NDKSubscription = undefined; let sub: NDKSubscription = undefined;
if (status === 'success' && ark.account) { if (status === 'success' && storage.account) {
const filter = { const filter = {
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap], kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
'#p': [ark.account.pubkey], '#p': [storage.account.pubkey],
since: Math.floor(Date.now() / 1000), since: Math.floor(Date.now() / 1000),
}; };
@@ -85,17 +85,6 @@ export function NotificationWidget() {
return await sendNativeNotification( return await sendNativeNotification(
`${profile.displayName || profile.name} has replied to your note` `${profile.displayName || profile.name} has replied to your note`
); );
case NDKKind.EncryptedDirectMessage: {
if (location.pathname !== '/chats') {
return await sendNativeNotification(
`${
profile.displayName || profile.name
} has send you a encrypted message`
);
} else {
break;
}
}
case NDKKind.Repost: case NDKKind.Repost:
return await sendNativeNotification( return await sendNativeNotification(
`${profile.displayName || profile.name} has reposted to your note` `${profile.displayName || profile.name} has reposted to your note`
@@ -133,11 +122,7 @@ export function NotificationWidget() {
<Widget.Content> <Widget.Content>
<VList className="flex-1" overscan={2}> <VList className="flex-1" overscan={2}>
{status === 'pending' ? ( {status === 'pending' ? (
<div className="px-3 py-1.5"> <NoteSkeleton />
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div>
) : allEvents.length < 1 ? ( ) : allEvents.length < 1 ? (
<div className="my-3 flex w-full items-center justify-center gap-2"> <div className="my-3 flex w-full items-center justify-center gap-2">
<div>🎉</div> <div>🎉</div>

View File

@@ -1,40 +1,21 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { VList, VListHandle } from 'virtua'; import { VList, VListHandle } from 'virtua';
import { useArk } from '@libs/ark'; import { NewsfeedWidget, NotificationWidget } from '@app/home/components';
import { useStorage } from '@libs/ark';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import {
ArticleWidget,
FileWidget,
GroupWidget,
HashtagWidget,
NewsfeedWidget,
NotificationWidget,
ThreadWidget,
TopicWidget,
TrendingAccountsWidget,
TrendingNotesWidget,
UserWidget,
WidgetList,
} from '@shared/widgets';
import { WIDGET_KIND } from '@utils/constants'; import { WIDGET_KIND } from '@utils/constants';
import { WidgetProps } from '@utils/types'; import { WidgetProps } from '@utils/types';
export function HomeScreen() { export function HomeScreen() {
const ark = useArk(); const storage = useStorage();
const ref = useRef<VListHandle>(null); const ref = useRef<VListHandle>(null);
const { isLoading, data } = useQuery({ const { isLoading, data } = useQuery({
queryKey: ['widgets'], queryKey: ['widgets'],
queryFn: async () => { queryFn: async () => {
const dbWidgets = await ark.getWidgets(); const dbWidgets = await storage.getWidgets();
const defaultWidgets = [ const defaultWidgets = [
{
id: '9998',
title: 'Notification',
content: '',
kind: WIDGET_KIND.notification,
},
{ {
id: '9999', id: '9999',
title: 'Newsfeed', title: 'Newsfeed',
@@ -59,26 +40,6 @@ export function HomeScreen() {
return <NotificationWidget key={widget.id} />; return <NotificationWidget key={widget.id} />;
case WIDGET_KIND.newsfeed: case WIDGET_KIND.newsfeed:
return <NewsfeedWidget key={widget.id} />; return <NewsfeedWidget key={widget.id} />;
case WIDGET_KIND.topic:
return <TopicWidget key={widget.id} props={widget} />;
case WIDGET_KIND.user:
return <UserWidget key={widget.id} props={widget} />;
case WIDGET_KIND.thread:
return <ThreadWidget key={widget.id} props={widget} />;
case WIDGET_KIND.article:
return <ArticleWidget key={widget.id} props={widget} />;
case WIDGET_KIND.file:
return <FileWidget key={widget.id} props={widget} />;
case WIDGET_KIND.hashtag:
return <HashtagWidget key={widget.id} props={widget} />;
case WIDGET_KIND.group:
return <GroupWidget key={widget.id} props={widget} />;
case WIDGET_KIND.trendingNotes:
return <TrendingNotesWidget key={widget.id} props={widget} />;
case WIDGET_KIND.trendingAccounts:
return <TrendingAccountsWidget key={widget.id} props={widget} />;
case WIDGET_KIND.list:
return <WidgetList key={widget.id} props={widget} />;
default: default:
return <NewsfeedWidget key={widget.id} />; return <NewsfeedWidget key={widget.id} />;
} }

View File

@@ -66,7 +66,7 @@ export function NewArticleScreen() {
const submit = async () => { const submit = async () => {
try { try {
if (!ark.readyToSign) return navigate('/new/privkey'); if (!ark.ndk.signer) return navigate('/new/privkey');
setLoading(true); setLoading(true);

View File

@@ -2,11 +2,11 @@ import * as Popover from '@radix-ui/react-popover';
import { Editor } from '@tiptap/react'; import { Editor } from '@tiptap/react';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { MentionPopupItem } from '@app/new/components'; import { MentionPopupItem } from '@app/new/components';
import { useArk } from '@libs/ark'; import { useStorage } from '@libs/ark';
import { MentionIcon } from '@shared/icons'; import { MentionIcon } from '@shared/icons';
export function MentionPopup({ editor }: { editor: Editor }) { export function MentionPopup({ editor }: { editor: Editor }) {
const ark = useArk(); const storage = useStorage();
const insertMention = (pubkey: string) => { const insertMention = (pubkey: string) => {
editor.commands.insertContent(`nostr:${nip19.npubEncode(pubkey)}`); editor.commands.insertContent(`nostr:${nip19.npubEncode(pubkey)}`);
@@ -29,8 +29,8 @@ export function MentionPopup({ editor }: { editor: Editor }) {
className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900" className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900"
> >
<div className="flex flex-col gap-1 py-1"> <div className="flex flex-col gap-1 py-1">
{ark.account.contacts.length ? ( {storage.account.contacts.length ? (
ark.account.contacts.map((item) => ( storage.account.contacts.map((item) => (
<button key={item} type="button" onClick={() => insertMention(item)}> <button key={item} type="button" onClick={() => insertMention(item)}>
<MentionPopupItem pubkey={item} /> <MentionPopupItem pubkey={item} />
</button> </button>

View File

@@ -83,7 +83,7 @@ export function NewFileScreen() {
const submit = async () => { const submit = async () => {
try { try {
if (!ark.readyToSign) return navigate('/new/privkey'); if (!ark.ndk.signer) return navigate('/new/privkey');
setIsPublish(true); setIsPublish(true);

View File

@@ -11,12 +11,10 @@ import { useLayoutEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { MediaUploader, MentionPopup } from '@app/new/components'; import { MediaUploader, MentionPopup } from '@app/new/components';
import { useArk } from '@libs/ark'; import { MentionNote, useArk, useWidget } from '@libs/ark';
import { CancelIcon, LoaderIcon } from '@shared/icons'; import { CancelIcon, LoaderIcon } from '@shared/icons';
import { MentionNote } from '@shared/notes';
import { WIDGET_KIND } from '@utils/constants'; import { WIDGET_KIND } from '@utils/constants';
import { useSuggestion } from '@utils/hooks/useSuggestion'; import { useSuggestion } from '@utils/hooks/useSuggestion';
import { useWidget } from '@utils/hooks/useWidget';
export function NewPostScreen() { export function NewPostScreen() {
const ark = useArk(); const ark = useArk();
@@ -64,7 +62,7 @@ export function NewPostScreen() {
const submit = async () => { const submit = async () => {
try { try {
if (!ark.readyToSign) return navigate('/new/privkey'); if (!ark.ndk.signer) return navigate('/new/privkey');
setLoading(true); setLoading(true);
@@ -133,7 +131,7 @@ export function NewPostScreen() {
/> />
{searchParams.get('replyTo') && ( {searchParams.get('replyTo') && (
<div className="relative max-w-lg"> <div className="relative max-w-lg">
<MentionNote id={searchParams.get('replyTo')} editing /> <MentionNote eventId={searchParams.get('replyTo')} />
<button <button
type="button" type="button"
onClick={() => setSearchParams({})} onClick={() => setSearchParams({})}

View File

@@ -3,10 +3,11 @@ import { getPublicKey, nip19 } from 'nostr-tools';
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useArk } from '@libs/ark'; import { useArk, useStorage } from '@libs/ark';
export function NewPrivkeyScreen() { export function NewPrivkeyScreen() {
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const navigate = useNavigate(); const navigate = useNavigate();
const [nsec, setNsec] = useState(''); const [nsec, setNsec] = useState('');
@@ -23,7 +24,7 @@ export function NewPrivkeyScreen() {
const privkey = decoded.data; const privkey = decoded.data;
const pubkey = getPublicKey(privkey); const pubkey = getPublicKey(privkey);
if (pubkey !== ark.account.pubkey) if (pubkey !== storage.account.pubkey)
return toast.info( return toast.info(
'Your nsec is not match your current public key, please make sure you enter right nsec' 'Your nsec is not match your current public key, please make sure you enter right nsec'
); );
@@ -31,7 +32,7 @@ export function NewPrivkeyScreen() {
const signer = new NDKPrivateKeySigner(privkey); const signer = new NDKPrivateKeySigner(privkey);
ark.updateNostrSigner({ signer }); ark.updateNostrSigner({ signer });
if (isSave) await ark.createPrivkey(ark.account.pubkey, privkey); if (isSave) await storage.createPrivkey(storage.account.pubkey, privkey);
navigate(-1); navigate(-1);
} catch (e) { } catch (e) {

View File

@@ -1,124 +0,0 @@
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import Markdown from 'markdown-to-jsx';
import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/types/nip19';
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import { ArrowLeftIcon, CheckCircleIcon, ShareIcon } from '@shared/icons';
import { NoteReplyForm } from '@shared/notes';
import { ReplyList } from '@shared/notes/replies/list';
import { useEvent } from '@utils/hooks/useEvent';
export function ArticleNoteScreen() {
const { id } = useParams();
const { status, data } = useEvent(id);
const [isCopy, setIsCopy] = useState(false);
const navigate = useNavigate();
const metadata = useMemo(() => {
if (status === 'pending') return;
const title = data.tags.find((tag) => tag[0] === 'title')?.[1];
const image = data.tags.find((tag) => tag[0] === 'image')?.[1];
const summary = data.tags.find((tag) => tag[0] === 'summary')?.[1];
let publishedAt: Date | string | number = data.tags.find(
(tag) => tag[0] === 'published_at'
)?.[1];
publishedAt = new Date(parseInt(publishedAt) * 1000).toLocaleDateString('en-US');
return {
title,
image,
publishedAt,
summary,
};
}, [data]);
const share = async () => {
try {
await writeText(
'https://njump.me/' +
nip19.neventEncode({ id: data?.id, author: data?.pubkey } as EventPointer)
);
// update state
setIsCopy(true);
// reset state after 2 sec
setTimeout(() => setIsCopy(false), 2000);
} catch (e) {
toast.error(e);
}
};
return (
<div className="grid grid-cols-12 scroll-smooth px-4">
<div className="col-span-1 flex flex-col items-start">
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100 dark:bg-neutral-900"
>
<ArrowLeftIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={share}
className="inline-flex h-12 w-12 items-center justify-center rounded-t-xl"
>
{isCopy ? (
<CheckCircleIcon className="h-5 w-5 text-teal-500" />
) : (
<ShareIcon className="h-5 w-5" />
)}
</button>
</div>
<div className="col-span-7 overflow-y-auto px-3 xl:col-span-8">
{status === 'pending' ? (
<div className="px-3 py-1.5">Loading...</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 border-b border-neutral-100 pb-4 dark:border-neutral-900">
{metadata.image && (
<img
src={metadata.image}
alt={metadata.title}
className="h-auto w-full rounded-lg object-cover"
/>
)}
<div>
<h1 className="mb-2 text-3xl font-semibold">{metadata.title}</h1>
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
Published: {metadata.publishedAt.toString()}
</span>
</div>
</div>
<Markdown
options={{
overrides: {
a: {
props: {
className: 'text-blue-500 hover:text-blue-600',
target: '_blank',
},
},
},
}}
className="break-p prose-lg prose-neutral dark:prose-invert prose-ul:list-disc"
>
{data.content}
</Markdown>
</div>
)}
</div>
<div className="col-span-4 border-l border-neutral-100 px-3 dark:border-neutral-900 xl:col-span-3">
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900">
<NoteReplyForm rootEvent={data} />
</div>
<ReplyList eventId={id} />
</div>
</div>
);
}

View File

@@ -1,133 +0,0 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/types/nip19';
import { useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import { useArk } from '@libs/ark';
import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons';
import {
ChildNote,
MemoizedTextKind,
NoteActions,
NoteReplyForm,
UnknownNote,
} from '@shared/notes';
import { ReplyList } from '@shared/notes/replies/list';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
export function TextNoteScreen() {
const navigate = useNavigate();
const replyRef = useRef(null);
const { id } = useParams();
const ark = useArk();
const { status, data } = useEvent(id);
const [isCopy, setIsCopy] = useState(false);
const share = async () => {
try {
await writeText(
'https://njump.me/' +
nip19.neventEncode({ id: data?.id, author: data?.pubkey } as EventPointer)
);
// update state
setIsCopy(true);
// reset state after 2 sec
setTimeout(() => setIsCopy(false), 2000);
} catch (e) {
toast.error(e);
}
};
const scrollToReply = () => {
replyRef.current.scrollIntoView();
};
const renderKind = (event: NDKEvent) => {
const thread = ark.getEventThread({ tags: event.tags });
switch (event.kind) {
case NDKKind.Text:
return (
<>
{thread ? (
<div className="mb-2 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 ? (
<ChildNote id={thread.rootEventId} isRoot />
) : null}
{thread.replyEventId ? <ChildNote id={thread.replyEventId} /> : null}
</div>
</div>
) : null}
<MemoizedTextKind content={event.content} />
</>
);
default:
return <UnknownNote event={event} />;
}
};
return (
<div className="container mx-auto grid grid-cols-8 scroll-smooth px-4">
<div className="col-span-1">
<div className="flex flex-col items-end gap-4">
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100 dark:bg-neutral-900"
>
<ArrowLeftIcon className="h-5 w-5" />
</button>
<div className="flex flex-col divide-y divide-neutral-200 rounded-xl bg-neutral-100 dark:divide-neutral-800 dark:bg-neutral-900">
<button
type="button"
onClick={share}
className="inline-flex h-12 w-12 items-center justify-center rounded-t-xl"
>
{isCopy ? (
<CheckCircleIcon className="h-5 w-5 text-teal-500" />
) : (
<ShareIcon className="h-5 w-5" />
)}
</button>
<button
type="button"
onClick={scrollToReply}
className="inline-flex h-12 w-12 items-center justify-center rounded-b-xl"
>
<ReplyIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
<div className="relative col-span-6 flex flex-col overflow-y-auto">
<div className="mx-auto w-full max-w-2xl">
{status === 'pending' ? (
<div className="px-3 py-1.5">Loading...</div>
) : (
<div className="flex h-min w-full flex-col px-3">
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900">
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
<div className="mt-3">{renderKind(data)}</div>
<div className="mt-3">
<NoteActions event={data} canOpenEvent={false} />
</div>
</div>
</div>
)}
<div ref={replyRef} className="px-3">
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900">
<NoteReplyForm rootEvent={data} />
</div>
<ReplyList eventId={id} />
</div>
</div>
</div>
<div className="col-span-1" />
</div>
);
}

View File

@@ -1,10 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useArk } from '@libs/ark'; import { useStorage } from '@libs/ark';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
export function NWCForm({ setWalletConnectURL }) { export function NWCForm({ setWalletConnectURL }) {
const ark = useArk(); const storage = useStorage();
const [uri, setUri] = useState(''); const [uri, setUri] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -25,7 +25,7 @@ export function NWCForm({ setWalletConnectURL }) {
const params = new URLSearchParams(uriObj.search); const params = new URLSearchParams(uriObj.search);
if (params.has('relay') && params.has('secret')) { if (params.has('relay') && params.has('secret')) {
await ark.createPrivkey(`${ark.account.pubkey}-nwc`, uri); await storage.createPrivkey(`${storage.account.pubkey}-nwc`, uri);
setWalletConnectURL(uri); setWalletConnectURL(uri);
setLoading(false); setLoading(false);
} else { } else {

View File

@@ -1,20 +1,20 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { NWCForm } from '@app/nwc/components/form'; import { NWCForm } from '@app/nwc/components/form';
import { useArk } from '@libs/ark'; import { useStorage } from '@libs/ark';
import { CheckCircleIcon } from '@shared/icons'; import { CheckCircleIcon } from '@shared/icons';
export function NWCScreen() { export function NWCScreen() {
const ark = useArk(); const storage = useStorage();
const [walletConnectURL, setWalletConnectURL] = useState<null | string>(null); const [walletConnectURL, setWalletConnectURL] = useState<null | string>(null);
const remove = async () => { const remove = async () => {
await ark.removePrivkey(`${ark.account.pubkey}-nwc`); await storage.removePrivkey(`${storage.account.pubkey}-nwc`);
setWalletConnectURL(null); setWalletConnectURL(null);
}; };
useEffect(() => { useEffect(() => {
async function getNWC() { async function getNWC() {
const nwc = await ark.loadPrivkey(`${ark.account.pubkey}-nwc`); const nwc = await storage.loadPrivkey(`${storage.account.pubkey}-nwc`);
if (nwc) setWalletConnectURL(nwc); if (nwc) setWalletConnectURL(nwc);
} }
getNWC(); getNWC();

View File

@@ -2,14 +2,8 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { VList } from 'virtua'; import { VList } from 'virtua';
import { useArk } from '@libs/ark'; import { NoteSkeleton, RepostNote, TextNote, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import {
MemoizedRepost,
MemoizedTextNote,
NoteSkeleton,
UnknownNote,
} from '@shared/notes';
import { FETCH_LIMIT } from '@utils/constants'; import { FETCH_LIMIT } from '@utils/constants';
export function RelayEventList({ relayUrl }: { relayUrl: string }) { export function RelayEventList({ relayUrl }: { relayUrl: string }) {
@@ -55,11 +49,11 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
(event: NDKEvent) => { (event: NDKEvent) => {
switch (event.kind) { switch (event.kind) {
case NDKKind.Text: case NDKKind.Text:
return <MemoizedTextNote key={event.id} event={event} />; return <TextNote key={event.id} event={event} />;
case NDKKind.Repost: case NDKKind.Repost:
return <MemoizedRepost key={event.id} event={event} />; return <RepostNote key={event.id} event={event} />;
default: default:
return <UnknownNote key={event.id} event={event} />; return <TextNote key={event.id} event={event} />;
} }
}, },
[data] [data]
@@ -68,11 +62,7 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
return ( return (
<VList className="mx-auto h-full w-full max-w-[500px] pt-10 scrollbar-none"> <VList className="mx-auto h-full w-full max-w-[500px] pt-10 scrollbar-none">
{status === 'pending' ? ( {status === 'pending' ? (
<div className="px-3 py-1.5"> <NoteSkeleton />
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div>
) : ( ) : (
allEvents.map((item) => renderItem(item)) allEvents.map((item) => renderItem(item))
)} )}

View File

@@ -1,20 +1,22 @@
import { NDKKind } from '@nostr-dev-kit/ndk'; import { NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { RelayForm } from '@app/relays/components/relayForm'; import { RelayForm } from '@app/relays/components/relayForm';
import { useArk } from '@libs/ark'; import { useArk, useStorage } from '@libs/ark';
import { CancelIcon, RefreshIcon } from '@shared/icons'; import { CancelIcon, RefreshIcon } from '@shared/icons';
import { useRelay } from '@utils/hooks/useRelay'; import { useRelay } from '@utils/hooks/useRelay';
export function UserRelayList() { export function UserRelayList() {
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const { removeRelay } = useRelay(); const { removeRelay } = useRelay();
const { status, data, refetch } = useQuery({ const { status, data, refetch } = useQuery({
queryKey: ['relays', ark.account.pubkey], queryKey: ['relays', storage.account.pubkey],
queryFn: async () => { queryFn: async () => {
const event = await ark.getEventByFilter({ const event = await ark.getEventByFilter({
filter: { filter: {
kinds: [NDKKind.RelayList], kinds: [NDKKind.RelayList],
authors: [ark.account.pubkey], authors: [storage.account.pubkey],
}, },
}); });
@@ -24,7 +26,7 @@ export function UserRelayList() {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const currentRelays = new Set([...ark.relays]); const currentRelays = new Set(ark.ndk.pool.connectedRelays().map((item) => item.url));
return ( return (
<div className="col-span-1"> <div className="col-span-1">

View File

@@ -1,21 +1,21 @@
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useArk } from '@libs/ark'; import { useStorage } from '@libs/ark';
import { EyeOffIcon } from '@shared/icons'; import { EyeOffIcon } from '@shared/icons';
export function BackupSettingScreen() { export function BackupSettingScreen() {
const ark = useArk(); const storage = useStorage();
const [privkey, setPrivkey] = useState(null); const [privkey, setPrivkey] = useState(null);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const removePrivkey = async () => { const removePrivkey = async () => {
await ark.removePrivkey(ark.account.pubkey); await storage.removePrivkey(storage.account.pubkey);
}; };
useEffect(() => { useEffect(() => {
async function loadPrivkey() { async function loadPrivkey() {
const key = await ark.loadPrivkey(ark.account.pubkey); const key = await storage.loadPrivkey(storage.account.pubkey);
if (key) setPrivkey(key); if (key) setPrivkey(key);
} }

View File

@@ -1,17 +1,17 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http'; import { fetch } from '@tauri-apps/plugin-http';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useArk } from '@libs/ark'; import { useStorage } from '@libs/ark';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/formater'; import { compactNumber } from '@utils/formater';
export function PostCard() { export function PostCard() {
const ark = useArk(); const storage = useStorage();
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['user-stats', ark.account.pubkey], queryKey: ['user-stats', storage.account.pubkey],
queryFn: async ({ signal }: { signal: AbortSignal }) => { queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch( const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${ark.account.pubkey}`, `https://api.nostr.band/v0/stats/profile/${storage.account.pubkey}`,
{ {
signal, signal,
} }
@@ -38,14 +38,14 @@ export function PostCard() {
) : ( ) : (
<div className="flex h-full w-full flex-col justify-between p-4"> <div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100"> <h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data.stats[ark.account.pubkey].pub_note_count)} {compactNumber.format(data.stats[storage.account.pubkey].pub_note_count)}
</h3> </h3>
<div className="mt-auto flex h-6 w-full items-center justify-between"> <div className="mt-auto flex h-6 w-full items-center justify-between">
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400"> <p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
Posts Posts
</p> </p>
<Link <Link
to={`/users/${ark.account.pubkey}`} to={`/users/${storage.account.pubkey}`}
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
> >
View View

View File

@@ -3,21 +3,21 @@ import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { minidenticon } from 'minidenticons'; import { minidenticon } from 'minidenticons';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useArk } from '@libs/ark'; import { useStorage } from '@libs/ark';
import { EditIcon, LoaderIcon } from '@shared/icons'; import { EditIcon, LoaderIcon } from '@shared/icons';
import { displayNpub } from '@utils/formater'; import { displayNpub } from '@utils/formater';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
export function ProfileCard() { export function ProfileCard() {
const ark = useArk(); const storage = useStorage();
const svgURI = const svgURI =
'data:image/svg+xml;utf8,' + 'data:image/svg+xml;utf8,' +
encodeURIComponent(minidenticon(ark.account.pubkey, 90, 50)); encodeURIComponent(minidenticon(storage.account.pubkey, 90, 50));
const { isLoading, user } = useProfile(ark.account.pubkey); const { isLoading, user } = useProfile(storage.account.pubkey);
const copyNpub = async () => { const copyNpub = async () => {
return await writeText(nip19.npubEncode(ark.account.pubkey)); return await writeText(nip19.npubEncode(storage.account.pubkey));
}; };
return ( return (
@@ -48,7 +48,7 @@ export function ProfileCard() {
<Avatar.Root className="shrink-0"> <Avatar.Root className="shrink-0">
<Avatar.Image <Avatar.Image
src={user?.picture || user?.image} src={user?.picture || user?.image}
alt={ark.account.pubkey} alt={storage.account.pubkey}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
style={{ contentVisibility: 'auto' }} style={{ contentVisibility: 'auto' }}
@@ -57,7 +57,7 @@ export function ProfileCard() {
<Avatar.Fallback delayMs={300}> <Avatar.Fallback delayMs={300}>
<img <img
src={svgURI} src={svgURI}
alt={ark.account.pubkey} alt={storage.account.pubkey}
className="h-16 w-16 rounded-xl bg-black dark:bg-white" className="h-16 w-16 rounded-xl bg-black dark:bg-white"
/> />
</Avatar.Fallback> </Avatar.Fallback>
@@ -67,7 +67,7 @@ export function ProfileCard() {
{user?.display_name || user?.name} {user?.display_name || user?.name}
</h3> </h3>
<p className="text-lg text-neutral-700 dark:text-neutral-300"> <p className="text-lg text-neutral-700 dark:text-neutral-300">
{user?.nip05 || displayNpub(ark.account.pubkey, 16)} {user?.nip05 || displayNpub(storage.account.pubkey, 16)}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,13 +1,15 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useArk } from '@libs/ark'; import { useArk, useStorage } from '@libs/ark';
import { EditIcon, LoaderIcon } from '@shared/icons'; import { EditIcon, LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/formater'; import { compactNumber } from '@utils/formater';
export function RelayCard() { export function RelayCard() {
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['relays', ark.account.pubkey], queryKey: ['relays', storage.account.pubkey],
queryFn: async () => { queryFn: async () => {
const relays = await ark.getUserRelays({}); const relays = await ark.getUserRelays({});
return relays; return relays;

View File

@@ -1,16 +1,16 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http'; import { fetch } from '@tauri-apps/plugin-http';
import { useArk } from '@libs/ark'; import { useStorage } from '@libs/ark';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/formater'; import { compactNumber } from '@utils/formater';
export function ZapCard() { export function ZapCard() {
const ark = useArk(); const storage = useStorage();
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['user-stats', ark.account.pubkey], queryKey: ['user-stats', storage.account.pubkey],
queryFn: async ({ signal }: { signal: AbortSignal }) => { queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch( const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${ark.account.pubkey}`, `https://api.nostr.band/v0/stats/profile/${storage.account.pubkey}`,
{ {
signal, signal,
} }
@@ -38,7 +38,7 @@ export function ZapCard() {
<div className="flex h-full w-full flex-col justify-between p-4"> <div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100"> <h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format( {compactNumber.format(
data?.stats[ark.account.pubkey]?.zaps_received?.msats / 1000 || 0 data?.stats[storage.account.pubkey]?.zaps_received?.msats / 1000 || 0
)} )}
</h3> </h3>
<div className="mt-auto flex h-6 items-center text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400"> <div className="mt-auto flex h-6 items-center text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">

View File

@@ -4,7 +4,7 @@ import { message } from '@tauri-apps/plugin-dialog';
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useArk } from '@libs/ark'; import { useArk, useStorage } from '@libs/ark';
import { CheckCircleIcon, LoaderIcon, PlusIcon, UnverifiedIcon } from '@shared/icons'; import { CheckCircleIcon, LoaderIcon, PlusIcon, UnverifiedIcon } from '@shared/icons';
export function EditProfileScreen() { export function EditProfileScreen() {
@@ -14,6 +14,8 @@ export function EditProfileScreen() {
const [nip05, setNIP05] = useState({ verified: true, text: '' }); const [nip05, setNIP05] = useState({ verified: true, text: '' });
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -22,7 +24,10 @@ export function EditProfileScreen() {
formState: { isValid, errors }, formState: { isValid, errors },
} = useForm({ } = useForm({
defaultValues: async () => { defaultValues: async () => {
const res: NDKUserProfile = queryClient.getQueryData(['user', ark.account.pubkey]); const res: NDKUserProfile = queryClient.getQueryData([
'user',
storage.account.pubkey,
]);
if (res.image) { if (res.image) {
setPicture(res.image); setPicture(res.image);
} }
@@ -41,7 +46,7 @@ export function EditProfileScreen() {
const uploadAvatar = async () => { const uploadAvatar = async () => {
try { try {
if (!ark.readyToSign) return navigate('/new/privkey'); if (!ark.ndk.signer) return navigate('/new/privkey');
setLoading(true); setLoading(true);
@@ -85,7 +90,10 @@ export function EditProfileScreen() {
}; };
if (data.nip05) { if (data.nip05) {
const verify = ark.validateNIP05({ pubkey: ark.account.pubkey, nip05: data.nip05 }); const verify = ark.validateNIP05({
pubkey: storage.account.pubkey,
nip05: data.nip05,
});
if (verify) { if (verify) {
content = { ...content, nip05: data.nip05 }; content = { ...content, nip05: data.nip05 };
} else { } else {
@@ -106,7 +114,7 @@ export function EditProfileScreen() {
if (publish) { if (publish) {
// invalid cache // invalid cache
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['user', ark.account.pubkey], queryKey: ['user', storage.account.pubkey],
}); });
// reset form // reset form
reset(); reset();

View File

@@ -5,11 +5,12 @@ import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart';
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification'; import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { useArk } from '@libs/ark'; import { useStorage } from '@libs/ark';
import { DarkIcon, LightIcon, SystemModeIcon } from '@shared/icons'; import { DarkIcon, LightIcon, SystemModeIcon } from '@shared/icons';
export function GeneralSettingScreen() { export function GeneralSettingScreen() {
const ark = useArk(); const storage = useStorage();
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
autoupdate: false, autoupdate: false,
autolaunch: false, autolaunch: false,
@@ -39,28 +40,28 @@ export function GeneralSettingScreen() {
}; };
const toggleOutbox = async () => { const toggleOutbox = async () => {
await ark.createSetting('outbox', String(+!settings.outbox)); await storage.createSetting('outbox', String(+!settings.outbox));
// update state // update state
setSettings((prev) => ({ ...prev, outbox: !settings.outbox })); setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
}; };
const toggleMedia = async () => { const toggleMedia = async () => {
await ark.createSetting('media', String(+!settings.media)); await storage.createSetting('media', String(+!settings.media));
ark.settings.media = !settings.media; storage.settings.media = !settings.media;
// update state // update state
setSettings((prev) => ({ ...prev, media: !settings.media })); setSettings((prev) => ({ ...prev, media: !settings.media }));
}; };
const toggleHashtag = async () => { const toggleHashtag = async () => {
await ark.createSetting('hashtag', String(+!settings.hashtag)); await storage.createSetting('hashtag', String(+!settings.hashtag));
ark.settings.hashtag = !settings.hashtag; storage.settings.hashtag = !settings.hashtag;
// update state // update state
setSettings((prev) => ({ ...prev, hashtag: !settings.hashtag })); setSettings((prev) => ({ ...prev, hashtag: !settings.hashtag }));
}; };
const toggleAutoupdate = async () => { const toggleAutoupdate = async () => {
await ark.createSetting('autoupdate', String(+!settings.autoupdate)); await storage.createSetting('autoupdate', String(+!settings.autoupdate));
ark.settings.autoupdate = !settings.autoupdate; storage.settings.autoupdate = !settings.autoupdate;
// update state // update state
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate })); setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
}; };
@@ -84,7 +85,7 @@ export function GeneralSettingScreen() {
const permissionGranted = await isPermissionGranted(); const permissionGranted = await isPermissionGranted();
setSettings((prev) => ({ ...prev, notification: permissionGranted })); setSettings((prev) => ({ ...prev, notification: permissionGranted }));
const data = await ark.getAllSettings(); const data = await storage.getAllSettings();
if (!data) return; if (!data) return;
data.forEach((item) => { data.forEach((item) => {

View File

@@ -4,13 +4,15 @@ import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { UserStats } from '@app/users/components/stats'; import { UserStats } from '@app/users/components/stats';
import { useArk } from '@libs/ark'; import { useArk, useStorage } from '@libs/ark';
import { NIP05 } from '@shared/nip05'; import { NIP05 } from '@shared/nip05';
import { displayNpub } from '@utils/formater'; import { displayNpub } from '@utils/formater';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
export function UserProfile({ pubkey }: { pubkey: string }) { export function UserProfile({ pubkey }: { pubkey: string }) {
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const { user } = useProfile(pubkey); const { user } = useProfile(pubkey);
const [followed, setFollowed] = useState(false); const [followed, setFollowed] = useState(false);
@@ -21,7 +23,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
const follow = async () => { const follow = async () => {
try { try {
if (!ark.readyToSign) return navigate('/new/privkey'); if (!ark.ndk.signer) return navigate('/new/privkey');
setFollowed(true); setFollowed(true);
const add = await ark.createContact({ pubkey }); const add = await ark.createContact({ pubkey });
@@ -38,7 +40,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
const unfollow = async () => { const unfollow = async () => {
try { try {
if (!ark.readyToSign) return navigate('/new/privkey'); if (!ark.ndk.signer) return navigate('/new/privkey');
setFollowed(false); setFollowed(false);
await ark.deleteContact({ pubkey }); await ark.deleteContact({ pubkey });
@@ -48,7 +50,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
}; };
useEffect(() => { useEffect(() => {
if (ark.account.contacts.includes(pubkey)) { if (storage.account.contacts.includes(pubkey)) {
setFollowed(true); setFollowed(true);
} }
}, []); }, []);

View File

@@ -3,14 +3,8 @@ import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { UserProfile } from '@app/users/components/profile'; import { UserProfile } from '@app/users/components/profile';
import { useArk } from '@libs/ark'; import { NoteSkeleton, RepostNote, TextNote, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import {
MemoizedRepost,
MemoizedTextNote,
NoteSkeleton,
UnknownNote,
} from '@shared/notes';
import { FETCH_LIMIT } from '@utils/constants'; import { FETCH_LIMIT } from '@utils/constants';
export function UserScreen() { export function UserScreen() {
@@ -57,11 +51,11 @@ export function UserScreen() {
(event: NDKEvent) => { (event: NDKEvent) => {
switch (event.kind) { switch (event.kind) {
case NDKKind.Text: case NDKKind.Text:
return <MemoizedTextNote key={event.id} event={event} />; return <TextNote key={event.id} event={event} />;
case NDKKind.Repost: case NDKKind.Repost:
return <MemoizedRepost key={event.id} event={event} />; return <RepostNote key={event.id} event={event} />;
default: default:
return <UnknownNote key={event.id} event={event} />; return <TextNote key={event.id} event={event} />;
} }
}, },
[data] [data]
@@ -76,11 +70,7 @@ export function UserScreen() {
</h3> </h3>
<div className="mx-auto flex h-full max-w-[500px] flex-col justify-between gap-1.5 pb-4 pt-1.5"> <div className="mx-auto flex h-full max-w-[500px] flex-col justify-between gap-1.5 pb-4 pt-1.5">
{status === 'pending' ? ( {status === 'pending' ? (
<div className="px-3 py-1.5"> <NoteSkeleton />
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div>
) : ( ) : (
allEvents.map((item) => renderItem(item)) allEvents.map((item) => renderItem(item))
)} )}

View File

@@ -10,76 +10,37 @@ import NDK, {
NDKUser, NDKUser,
NostrEvent, NostrEvent,
} from '@nostr-dev-kit/ndk'; } from '@nostr-dev-kit/ndk';
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
import { appConfigDir, resolveResource } from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api/primitives';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { readBinaryFile } from '@tauri-apps/plugin-fs'; import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { fetch } from '@tauri-apps/plugin-http'; import { fetch } from '@tauri-apps/plugin-http';
import { Platform } from '@tauri-apps/plugin-os'; import { NostrFetcher, normalizeRelayUrl } from 'nostr-fetch';
import { Child, Command } from '@tauri-apps/plugin-shell';
import Database from '@tauri-apps/plugin-sql';
import {
NostrEventExt,
NostrFetcher,
normalizeRelayUrl,
normalizeRelayUrlSet,
} from 'nostr-fetch';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { NDKCacheAdapterTauri } from '@libs/cache'; import { LumeStorage } from '@libs/storage';
import { delay } from '@utils/delay'; import { Account, type NDKEventWithReplies, type NIP05 } from '@utils/types';
import {
type Account,
type NDKCacheUser,
type NDKCacheUserProfile,
type NDKEventWithReplies,
type NIP05,
type WidgetProps,
} from '@utils/types';
export class Ark { export class Ark {
#storage: Database; #storage: LumeStorage;
#depot: Child; #fetcher: NostrFetcher;
public ndk: NDK; public ndk: NDK;
public fetcher: NostrFetcher; public account: Account;
public account: Account | null;
public relays: string[] | null;
public readyToSign: boolean;
readonly platform: Platform | null;
readonly settings: {
autoupdate: boolean;
bunker: boolean;
outbox: boolean;
media: boolean;
hashtag: boolean;
depot: boolean;
tunnelUrl: string;
};
constructor({ storage, platform }: { storage: Database; platform: Platform }) { constructor({
ndk,
storage,
fetcher,
}: {
ndk: NDK;
storage: LumeStorage;
fetcher: NostrFetcher;
}) {
this.ndk = ndk;
this.#storage = storage; this.#storage = storage;
this.platform = platform; this.#fetcher = fetcher;
this.settings = {
autoupdate: false,
bunker: false,
outbox: false,
media: true,
hashtag: true,
depot: false,
tunnelUrl: '',
};
}
public async launchDepot() {
const configPath = await resolveResource('resources/config.toml');
const dataPath = await appConfigDir();
const command = Command.sidecar('bin/depot', ['-c', configPath, '-d', dataPath]);
this.#depot = await command.spawn();
} }
public async connectDepot() { public async connectDepot() {
if (!this.#depot) return;
return this.ndk.addExplicitRelay( return this.ndk.addExplicitRelay(
new NDKRelay(normalizeRelayUrl('ws://localhost:6090')), new NDKRelay(normalizeRelayUrl('ws://localhost:6090')),
undefined, undefined,
@@ -87,349 +48,11 @@ export class Ark {
); );
} }
public checkDepot() {
if (this.#depot) return true;
return false;
}
async #keyring_save(key: string, value: string) {
return await invoke('secure_save', { key, value });
}
async #keyring_load(key: string) {
try {
const value: string = await invoke('secure_load', { key });
if (!value) return null;
return value;
} catch {
return null;
}
}
async #keyring_remove(key: string) {
return await invoke('secure_remove', { key });
}
async #initNostrSigner({ nsecbunker }: { nsecbunker?: boolean }) {
const account = await this.getActiveAccount();
if (!account) return null;
// update active account
this.account = account;
try {
// NIP-46 Signer
if (nsecbunker) {
const localSignerPrivkey = await this.#keyring_load(
`${this.account.id}-nsecbunker`
);
if (!localSignerPrivkey) {
this.readyToSign = false;
return null;
}
const localSigner = new NDKPrivateKeySigner(localSignerPrivkey);
const bunker = new NDK({
explicitRelayUrls: normalizeRelayUrlSet([
'wss://relay.nsecbunker.com/',
'wss://nostr.vulpem.com/',
]),
});
await bunker.connect(3000);
const remoteSigner = new NDKNip46Signer(bunker, this.account.pubkey, localSigner);
await remoteSigner.blockUntilReady();
this.readyToSign = true;
return remoteSigner;
}
// Privkey Signer
const userPrivkey = await this.#keyring_load(this.account.pubkey);
if (!userPrivkey) {
this.readyToSign = false;
return null;
}
this.readyToSign = true;
return new NDKPrivateKeySigner(userPrivkey);
} catch (e) {
console.log(e);
return null;
}
}
public async init() {
const settings = await this.getAllSettings();
for (const item of settings) {
if (item.key === 'nsecbunker') this.settings.bunker = !!parseInt(item.value);
if (item.key === 'outbox') this.settings.outbox = !!parseInt(item.value);
if (item.key === 'media') this.settings.media = !!parseInt(item.value);
if (item.key === 'hashtag') this.settings.hashtag = !!parseInt(item.value);
if (item.key === 'autoupdate') this.settings.autoupdate = !!parseInt(item.value);
if (item.key === 'depot') this.settings.depot = !!parseInt(item.value);
if (item.key === 'tunnel_url') this.settings.tunnelUrl = item.value;
}
const explicitRelayUrls = normalizeRelayUrlSet([
'wss://relay.damus.io',
'wss://relay.nostr.band/all',
'wss://nostr.mutinywallet.com',
]);
if (this.settings.depot) {
await this.launchDepot();
await delay(2000);
explicitRelayUrls.push(normalizeRelayUrl('ws://localhost:6090'));
}
// #TODO: user should config outbox relays
const outboxRelayUrls = normalizeRelayUrlSet(['wss://purplepag.es']);
// #TODO: user should config blacklist relays
// No need to connect depot tunnel url
const blacklistRelayUrls = this.settings.tunnelUrl.length
? [this.settings.tunnelUrl, this.settings.tunnelUrl + '/']
: [];
const cacheAdapter = new NDKCacheAdapterTauri(this.#storage);
const ndk = new NDK({
cacheAdapter,
explicitRelayUrls,
outboxRelayUrls,
blacklistRelayUrls,
enableOutboxModel: this.settings.outbox,
autoConnectUserRelays: true,
autoFetchUserMutelist: true,
// clientName: 'Lume',
// clientNip89: '',
});
// add signer if exist
const signer = await this.#initNostrSigner({ nsecbunker: this.settings.bunker });
if (signer) ndk.signer = signer;
// connect
await ndk.connect(3000);
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(ndk));
// update account's metadata
if (this.account) {
const user = ndk.getUser({ pubkey: this.account.pubkey });
ndk.activeUser = user;
const contacts = await user.follows();
this.account.contacts = [...contacts].map((user) => user.pubkey);
}
this.relays = [...ndk.pool.relays.values()].map((relay) => relay.url);
this.ndk = ndk;
this.fetcher = fetcher;
}
public updateNostrSigner({ signer }: { signer: NDKNip46Signer | NDKPrivateKeySigner }) { public updateNostrSigner({ signer }: { signer: NDKNip46Signer | NDKPrivateKeySigner }) {
this.ndk.signer = signer; this.ndk.signer = signer;
this.readyToSign = true;
return this.ndk.signer; return this.ndk.signer;
} }
public async getAllCacheUsers() {
const results: Array<NDKCacheUser> = await this.#storage.select(
'SELECT * FROM ndk_users ORDER BY createdAt DESC;'
);
if (!results.length) return [];
const users: NDKCacheUserProfile[] = results.map((item) => ({
pubkey: item.pubkey,
...JSON.parse(item.profile as string),
}));
return users;
}
public async checkAccount() {
const result: Array<{ total: string }> = await this.#storage.select(
'SELECT COUNT(*) AS "total" FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
);
return parseInt(result[0].total);
}
public async getActiveAccount() {
const results: Array<Account> = await this.#storage.select(
'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
);
if (results.length) {
return results[0];
} else {
return null;
}
}
public async createAccount({
id,
pubkey,
privkey,
}: {
id: string;
pubkey: string;
privkey?: string;
}) {
const existAccounts: Array<Account> = await this.#storage.select(
'SELECT * FROM accounts WHERE pubkey = $1 ORDER BY id DESC LIMIT 1;',
[pubkey]
);
if (existAccounts.length) {
await this.#storage.execute(
"UPDATE accounts SET is_active = '1' WHERE pubkey = $1;",
[pubkey]
);
} else {
await this.#storage.execute(
'INSERT OR IGNORE INTO accounts (id, pubkey, is_active) VALUES ($1, $2, $3);',
[id, pubkey, 1]
);
if (privkey) await this.#keyring_save(pubkey, privkey);
}
const account = await this.getActiveAccount();
this.account = account;
this.account.contacts = [];
return account;
}
/**
* Save private key to OS secure storage
* @deprecated this method will be remove in the next update
*/
public async createPrivkey(name: string, privkey: string) {
return await this.#keyring_save(name, privkey);
}
/**
* Load private key from OS secure storage
* @deprecated this method will be remove in the next update
*/
public async loadPrivkey(name: string) {
return await this.#keyring_load(name);
}
/**
* Remove private key from OS secure storage
* @deprecated this method will be remove in the next update
*/
public async removePrivkey(name: string) {
return await this.#keyring_remove(name);
}
public async updateAccount(column: string, value: string) {
const insert = await this.#storage.execute(
`UPDATE accounts SET ${column} = $1 WHERE id = $2;`,
[value, this.account.id]
);
if (insert) {
const account = await this.getActiveAccount();
return account;
}
}
public async getWidgets() {
const widgets: Array<WidgetProps> = await this.#storage.select(
'SELECT * FROM widgets WHERE account_id = $1 ORDER BY created_at DESC;',
[this.account.id]
);
return widgets;
}
public async createWidget(kind: number, title: string, content: string | string[]) {
const insert = await this.#storage.execute(
'INSERT INTO widgets (account_id, kind, title, content) VALUES ($1, $2, $3, $4);',
[this.account.id, kind, title, content]
);
if (insert) {
const widgets: Array<WidgetProps> = await this.#storage.select(
'SELECT * FROM widgets ORDER BY id DESC LIMIT 1;'
);
if (widgets.length < 1) console.error('get created widget failed');
return widgets[0];
} else {
console.error('create widget failed');
}
}
public async removeWidget(id: string) {
const res = await this.#storage.execute('DELETE FROM widgets WHERE id = $1;', [id]);
if (res) return id;
}
public async createSetting(key: string, value: string | undefined) {
const currentSetting = await this.checkSettingValue(key);
if (!currentSetting) {
return await this.#storage.execute(
'INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2);',
[key, value]
);
}
return await this.#storage.execute('UPDATE settings SET value = $1 WHERE key = $2;', [
value,
key,
]);
}
public async getAllSettings() {
const results: { key: string; value: string }[] = await this.#storage.select(
'SELECT * FROM settings ORDER BY id DESC;'
);
if (results.length < 1) return [];
return results;
}
public async checkSettingValue(key: string) {
const results: { key: string; value: string }[] = await this.#storage.select(
'SELECT * FROM settings WHERE key = $1 ORDER BY id DESC LIMIT 1;',
[key]
);
if (!results.length) return false;
return results[0].value;
}
public async getSettingValue(key: string) {
const results: { key: string; value: string }[] = await this.#storage.select(
'SELECT * FROM settings WHERE key = $1 ORDER BY id DESC LIMIT 1;',
[key]
);
if (!results.length) return '0';
return results[0].value;
}
public async clearCache() {
await this.#storage.execute('DELETE FROM ndk_events;');
await this.#storage.execute('DELETE FROM ndk_eventtags;');
await this.#storage.execute('DELETE FROM ndk_users;');
}
public async logout() {
await this.#keyring_remove(this.account.pubkey);
await this.#keyring_remove(`${this.account.pubkey}-nsecbunker`);
await this.#storage.execute("UPDATE accounts SET is_active = '0' WHERE id = $1;", [
this.account.id,
]);
this.account = null;
this.ndk.signer = null;
}
public subscribe({ public subscribe({
filter, filter,
closeOnEose = false, closeOnEose = false,
@@ -520,37 +143,52 @@ export class Ark {
outbox?: boolean; outbox?: boolean;
}) { }) {
try { try {
const user = this.ndk.getUser({ pubkey: pubkey ? pubkey : this.account.pubkey }); const user = this.ndk.getUser({
pubkey: pubkey ? pubkey : this.#storage.account.pubkey,
});
const contacts = [...(await user.follows(undefined, outbox))].map( const contacts = [...(await user.follows(undefined, outbox))].map(
(user) => user.pubkey (user) => user.pubkey
); );
if (pubkey === this.account.pubkey) this.account.contacts = contacts; if (pubkey === this.#storage.account.pubkey)
this.#storage.account.contacts = contacts;
return contacts; return contacts;
} catch (e) { } catch (e) {
throw new Error(e); throw new Error(e);
return [];
} }
} }
public async getUserRelays({ pubkey }: { pubkey?: string }) { public async getUserRelays({ pubkey }: { pubkey?: string }) {
try { try {
const user = this.ndk.getUser({ pubkey: pubkey ? pubkey : this.account.pubkey }); const user = this.ndk.getUser({
pubkey: pubkey ? pubkey : this.#storage.account.pubkey,
});
return await user.relayList(); return await user.relayList();
} catch (e) { } catch (e) {
throw new Error(e); throw new Error(e);
return null; }
}
public async newContactList({ tags }: { tags: NDKTag[] }) {
const publish = await this.createEvent({
kind: NDKKind.Contacts,
tags: tags,
});
if (publish) {
this.#storage.account.contacts = tags.map((item) => item[1]);
return publish;
} }
} }
public async createContact({ pubkey }: { pubkey: string }) { public async createContact({ pubkey }: { pubkey: string }) {
const user = this.ndk.getUser({ pubkey: this.account.pubkey }); const user = this.ndk.getUser({ pubkey: this.#storage.account.pubkey });
const contacts = await user.follows(); const contacts = await user.follows();
return await user.follow(new NDKUser({ pubkey: pubkey }), contacts); return await user.follow(new NDKUser({ pubkey: pubkey }), contacts);
} }
public async deleteContact({ pubkey }: { pubkey: string }) { public async deleteContact({ pubkey }: { pubkey: string }) {
const user = this.ndk.getUser({ pubkey: this.account.pubkey }); const user = this.ndk.getUser({ pubkey: this.#storage.account.pubkey });
const contacts = await user.follows(); const contacts = await user.follows();
contacts.delete(new NDKUser({ pubkey: pubkey })); contacts.delete(new NDKUser({ pubkey: pubkey }));
@@ -644,7 +282,7 @@ export class Ark {
if (!data) { if (!data) {
const relayUrls = [...this.ndk.pool.relays.values()].map((item) => item.url); const relayUrls = [...this.ndk.pool.relays.values()].map((item) => item.url);
const rawEvents = (await this.fetcher.fetchAllEvents( const rawEvents = (await this.#fetcher.fetchAllEvents(
relayUrls, relayUrls,
{ {
kinds: [NDKKind.Text], kinds: [NDKKind.Text],
@@ -686,11 +324,12 @@ export class Ark {
public async getAllRelaysFromContacts() { public async getAllRelaysFromContacts() {
const LIMIT = 1; const LIMIT = 1;
const connectedRelays = this.ndk.pool.connectedRelays().map((item) => item.url);
const relayMap = new Map<string, string[]>(); const relayMap = new Map<string, string[]>();
const relayEvents = this.fetcher.fetchLatestEventsPerAuthor( const relayEvents = this.#fetcher.fetchLatestEventsPerAuthor(
{ {
authors: this.account.contacts, authors: this.#storage.account.contacts,
relayUrls: this.relays, relayUrls: connectedRelays,
}, },
{ kinds: [NDKKind.RelayList] }, { kinds: [NDKKind.RelayList] },
LIMIT LIMIT
@@ -725,8 +364,9 @@ export class Ark {
}) { }) {
const rootIds = new Set(); const rootIds = new Set();
const dedupQueue = new Set(); const dedupQueue = new Set();
const connectedRelays = this.ndk.pool.connectedRelays().map((item) => item.url);
const events = await this.fetcher.fetchLatestEvents(this.relays, filter, limit, { const events = await this.#fetcher.fetchLatestEvents(connectedRelays, filter, limit, {
asOf: pageParam === 0 ? undefined : pageParam, asOf: pageParam === 0 ? undefined : pageParam,
abortSignal: signal, abortSignal: signal,
}); });
@@ -767,7 +407,7 @@ export class Ark {
signal?: AbortSignal; signal?: AbortSignal;
dedup?: boolean; dedup?: boolean;
}) { }) {
const events = await this.fetcher.fetchLatestEvents( const events = await this.#fetcher.fetchLatestEvents(
[normalizeRelayUrl(relayUrl)], [normalizeRelayUrl(relayUrl)],
filter, filter,
limit, limit,
@@ -856,107 +496,6 @@ export class Ark {
return false; return false;
} }
/**
* Return all NIP-04 messages
* @deprecated NIP-04 will be replace by NIP-44 in the next update
*/
public async getAllChats() {
const events = await this.fetcher.fetchAllEvents(
this.relays,
{
kinds: [NDKKind.EncryptedDirectMessage],
'#p': [this.account.pubkey],
},
{ since: 0 }
);
const dedup: NDKEvent[] = Object.values(
events.reduce((ev, { id, content, pubkey, created_at, tags }) => {
if (ev[pubkey]) {
if (ev[pubkey].created_at < created_at) {
ev[pubkey] = { id, content, pubkey, created_at, tags };
}
} else {
ev[pubkey] = { id, content, pubkey, created_at, tags };
}
return ev;
}, {})
);
return dedup;
}
/**
* Return all NIP-04 messages by pubkey
* @deprecated NIP-04 will be replace by NIP-44 in the next update
*/
public async getAllMessagesByPubkey({ pubkey }: { pubkey: string }) {
let senderMessages: NostrEventExt<false>[] = [];
if (pubkey !== this.account.pubkey) {
senderMessages = await this.fetcher.fetchAllEvents(
this.relays,
{
kinds: [NDKKind.EncryptedDirectMessage],
authors: [pubkey],
'#p': [this.account.pubkey],
},
{ since: 0 }
);
}
const userMessages = await this.fetcher.fetchAllEvents(
this.relays,
{
kinds: [NDKKind.EncryptedDirectMessage],
authors: [this.account.pubkey],
'#p': [pubkey],
},
{ since: 0 }
);
const all = [...senderMessages, ...userMessages].sort(
(a, b) => a.created_at - b.created_at
);
return all as unknown as NDKEvent[];
}
public async nip04Decrypt({ event }: { event: NDKEvent }) {
try {
const sender = new NDKUser({
pubkey:
this.account.pubkey === event.pubkey
? event.tags.find((el) => el[0] === 'p')[1]
: event.pubkey,
});
const content = await this.ndk.signer.decrypt(sender, event.content);
return content;
} catch (e) {
throw new Error(e);
}
}
public async nip04Encrypt({ content, pubkey }: { content: string; pubkey: string }) {
try {
const recipient = new NDKUser({ pubkey });
const message = await this.ndk.signer.encrypt(recipient, content);
const event = new NDKEvent(this.ndk);
event.content = message;
event.kind = NDKKind.EncryptedDirectMessage;
event.tag(recipient);
const publish = await event.publish();
if (!publish) throw new Error('Failed to send NIP-04 encrypted message');
return { id: event.id, seens: [...publish.values()].map((item) => item.url) };
} catch (e) {
throw new Error(e);
}
}
public async replyTo({ content, event }: { content: string; event: NDKEvent }) { public async replyTo({ content, event }: { content: string; event: NDKEvent }) {
try { try {
const replyEvent = new NDKEvent(this.ndk); const replyEvent = new NDKEvent(this.ndk);

View File

@@ -0,0 +1,67 @@
import * as Collapsible from '@radix-ui/react-collapsible';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { NavArrowDownIcon } from '@shared/icons';
import { User } from '@shared/user';
import { NDKEventWithReplies } from '@utils/types';
import { Note } from '..';
export function Reply({
event,
rootEvent,
}: {
event: NDKEventWithReplies;
rootEvent: string;
}) {
const [open, setOpen] = useState(false);
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Note.Root>
<Note.User pubkey={event.pubkey} time={event.created_at} className="h-14 px-3" />
<Note.TextContent content={event.content} className="min-w-0 px-3" />
<div className="-ml-1 flex items-center justify-between">
{event.replies?.length > 0 ? (
<Collapsible.Trigger asChild>
<div className="ml-4 inline-flex h-14 items-center gap-1 font-semibold text-blue-500">
<NavArrowDownIcon
className={twMerge('h-3 w-3', open ? 'rotate-180 transform' : '')}
/>
{event.replies?.length +
' ' +
(event.replies?.length === 1 ? 'reply' : 'replies')}
</div>
</Collapsible.Trigger>
) : null}
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
<Note.Reaction event={event} />
<Note.Repost event={event} />
<Note.Zap event={event} />
</div>
</div>
<div className={twMerge('px-3', open ? 'pb-3' : '')}>
{event.replies?.length > 0 ? (
<Collapsible.Content>
{event.replies?.map((childEvent) => (
<Note.Root key={childEvent.id}>
<User pubkey={event.pubkey} time={event.created_at} />
<Note.TextContent content={event.content} className="min-w-0 px-3" />
<div className="-ml-1 flex h-14 items-center justify-between px-3">
<Note.Pin eventId={event.id} />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
<Note.Reaction event={event} />
<Note.Repost event={event} />
<Note.Zap event={event} />
</div>
</div>
</Note.Root>
))}
</Collapsible.Content>
) : null}
</div>
</Note.Root>
</Collapsible.Root>
);
}

View File

@@ -1,17 +1,9 @@
import { NDKEvent, NDKKind, NostrEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind, NostrEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { memo } from 'react'; import { useArk } from '@libs/ark/provider';
import { useArk } from '@libs/ark'; import { Note } from '..';
import {
MemoizedArticleKind,
MemoizedFileKind,
MemoizedTextKind,
NoteActions,
NoteSkeleton,
} from '@shared/notes';
import { User } from '@shared/user';
export function Repost({ event }: { event: NDKEvent }) { export function RepostNote({ event }: { event: NDKEvent }) {
const ark = useArk(); const ark = useArk();
const { const {
isLoading, isLoading,
@@ -25,7 +17,6 @@ export function Repost({ event }: { event: NDKEvent }) {
const embed = JSON.parse(event.content) as NostrEvent; const embed = JSON.parse(event.content) as NostrEvent;
return new NDKEvent(ark.ndk, embed); return new NDKEvent(ark.ndk, embed);
} }
const id = event.tags.find((el) => el[0] === 'e')[1]; const id = event.tags.find((el) => el[0] === 'e')[1];
return await ark.getEventById({ id }); return await ark.getEventById({ id });
} catch { } catch {
@@ -39,29 +30,22 @@ export function Repost({ event }: { event: NDKEvent }) {
if (!repostEvent) return null; if (!repostEvent) return null;
switch (repostEvent.kind) { switch (repostEvent.kind) {
case NDKKind.Text: case NDKKind.Text:
return <MemoizedTextKind content={repostEvent.content} />; return <Note.TextContent content={repostEvent.content} />;
case 1063: case 1063:
return <MemoizedFileKind tags={repostEvent.tags} />; return <Note.MediaContent tags={repostEvent.tags} />;
case NDKKind.Article:
return <MemoizedArticleKind id={repostEvent.id} tags={repostEvent.tags} />;
default: default:
return null; return null;
} }
}; };
if (isLoading) { if (isLoading) {
return ( return <div className="w-full px-3 pb-3"></div>;
<div className="w-full px-3 pb-3">
<NoteSkeleton />
</div>
);
} }
if (isError) { if (isError) {
return ( return (
<div className="my-3 h-min w-full px-3"> <div className="my-3 h-min w-full px-3">
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950"> <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} variant="repost" />
<div className="relative flex flex-col gap-2"> <div className="relative flex flex-col gap-2">
<div className="px-3"> <div className="px-3">
<p>Failed to load event</p> <p>Failed to load event</p>
@@ -73,21 +57,26 @@ export function Repost({ event }: { event: NDKEvent }) {
} }
return ( return (
<div className="my-3 h-min w-full px-3"> <Note.Root>
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950"> <Note.User
<User pubkey={event.pubkey} time={event.created_at} variant="repost" /> pubkey={event.pubkey}
<div className="relative flex flex-col gap-2"> time={event.created_at}
<User variant="repost"
pubkey={repostEvent.pubkey} className="h-14"
time={repostEvent.created_at} />
eventId={repostEvent.id} <div className="relative flex flex-col gap-2 px-3">
/> <Note.User pubkey={repostEvent.pubkey} time={repostEvent.created_at} />
{renderContentByKind()} {renderContentByKind()}
<NoteActions event={repostEvent} /> <div className="flex h-14 items-center justify-between">
<Note.Pin eventId={event.id} />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={repostEvent.id} />
<Note.Reaction event={repostEvent} />
<Note.Repost event={repostEvent} />
<Note.Zap event={repostEvent} />
</div>
</div> </div>
</div> </div>
</div> </Note.Root>
); );
} }
export const MemoizedRepost = memo(Repost);

View File

@@ -0,0 +1,24 @@
import { Note } from '..';
export function NoteSkeleton() {
return (
<Note.Root>
<div className="flex h-min flex-col p-3">
<div className="flex items-start gap-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse overflow-hidden rounded-lg bg-neutral-400 dark:bg-neutral-600" />
<div className="h-6 w-full">
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
<div className="-mt-4 flex gap-3">
<div className="w-10 shrink-0" />
<div className="flex w-full flex-col gap-1">
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-1/2 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
</div>
</Note.Root>
);
}

View File

@@ -0,0 +1,25 @@
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} className="h-14 px-3" />
<Note.Thread thread={thread} className="mb-2" />
<Note.TextContent content={event.content} className="min-w-0 px-3" />
<div className="flex h-14 items-center justify-between px-3">
<Note.Pin eventId={event.id} />
<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

@@ -1,14 +1,24 @@
import * as Tooltip from '@radix-ui/react-tooltip'; import * as Tooltip from '@radix-ui/react-tooltip';
import { useWidget } from '@libs/ark';
import { PinIcon } from '@shared/icons'; import { PinIcon } from '@shared/icons';
import { WIDGET_KIND } from '@utils/constants';
export function NotePin({ eventId }: { eventId: string }) {
const { addWidget } = useWidget();
export function NotePin({ action }: { action: () => void }) {
return ( return (
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root delayDuration={150}> <Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={action} onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: eventId,
})
}
className="inline-flex h-7 w-max items-center justify-center gap-2 rounded-full bg-neutral-100 px-2 text-sm font-medium dark:bg-neutral-900" className="inline-flex h-7 w-max items-center justify-center gap-2 rounded-full bg-neutral-100 px-2 text-sm font-medium dark:bg-neutral-900"
> >
<PinIcon className="size-4" /> <PinIcon className="size-4" />

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

@@ -8,7 +8,7 @@ import { QRCodeSVG } from 'qrcode.react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import CurrencyInput from 'react-currency-input-field'; import CurrencyInput from 'react-currency-input-field';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useArk } from '@libs/ark'; import { useArk, useStorage } from '@libs/ark';
import { CancelIcon, ZapIcon } from '@shared/icons'; import { CancelIcon, ZapIcon } from '@shared/icons';
import { compactNumber, displayNpub } from '@utils/formater'; import { compactNumber, displayNpub } from '@utils/formater';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
@@ -26,12 +26,13 @@ export function NoteZap({ event }: { event: NDKEvent }) {
const { user } = useProfile(event.pubkey); const { user } = useProfile(event.pubkey);
const ark = useArk(); const ark = useArk();
const storage = useStorage();
const nwc = useRef(null); const nwc = useRef(null);
const navigate = useNavigate(); const navigate = useNavigate();
const createZapRequest = async () => { const createZapRequest = async () => {
try { try {
if (!ark.readyToSign) return navigate('/new/privkey'); if (!ark.ndk.signer) return navigate('/new/privkey');
const zapAmount = parseInt(amount) * 1000; const zapAmount = parseInt(amount) * 1000;
const res = await event.zap(zapAmount, zapMessage); const res = await event.zap(zapAmount, zapMessage);
@@ -82,7 +83,7 @@ export function NoteZap({ event }: { event: NDKEvent }) {
useEffect(() => { useEffect(() => {
async function getWalletConnectURL() { async function getWalletConnectURL() {
const uri: string = await invoke('secure_load', { const uri: string = await invoke('secure_load', {
key: `${ark.account.pubkey}-nwc`, key: `${storage.account.pubkey}-nwc`,
}); });
if (uri) setWalletConnectURL(uri); if (uri) setWalletConnectURL(uri);
} }

View File

@@ -1,27 +1,8 @@
import { useQuery } from '@tanstack/react-query'; import { useEvent } from '@libs/ark';
import { useArk } from '@libs/ark';
import { NoteChildUser } from './childUser'; import { NoteChildUser } from './childUser';
export function NoteChild({ eventId, isRoot }: { eventId: string; isRoot?: boolean }) { export function NoteChild({ eventId, isRoot }: { eventId: string; isRoot?: boolean }) {
const ark = useArk(); const { isLoading, isError, data } = useEvent(eventId);
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) { if (isLoading) {
return ( return (

View File

@@ -1,39 +1,17 @@
import * as Avatar from '@radix-ui/react-avatar'; import * as Avatar from '@radix-ui/react-avatar';
import { useQuery } from '@tanstack/react-query';
import { minidenticon } from 'minidenticons'; import { minidenticon } from 'minidenticons';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useArk } from '@libs/ark'; import { useProfile } from '@libs/ark';
import { displayNpub } from '@utils/formater'; import { displayNpub } from '@utils/formater';
export function NoteChildUser({ pubkey, subtext }: { pubkey: string; subtext: string }) { export function NoteChildUser({ pubkey, subtext }: { pubkey: string; subtext: string }) {
const ark = useArk();
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]); const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo( const fallbackAvatar = useMemo(
() => 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50)), () => 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50)),
[pubkey] [pubkey]
); );
const { isLoading, data: user } = useQuery({ const { isLoading, user } = useProfile(pubkey);
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) { if (isLoading) {
return ( return (

View File

@@ -1,23 +1,42 @@
import { NotePin } from './buttons/pin';
import { NoteReaction } from './buttons/reaction';
import { NoteReply } from './buttons/reply';
import { NoteRepost } from './buttons/repost';
import { NoteZap } from './buttons/zap';
import { NoteChild } from './child'; import { NoteChild } from './child';
import { NoteKind } from './kind'; import { NoteArticleContent } from './kinds/article';
import { NoteMediaContent } from './kinds/media';
import { NoteTextContent } from './kinds/text';
import { NoteMenu } from './menu'; import { NoteMenu } from './menu';
import { NotePin } from './pin'; import { NoteReplies } from './reply';
import { NoteReaction } from './reaction';
import { NoteReply } from './reply';
import { NoteRepost } from './repost';
import { NoteRoot } from './root'; import { NoteRoot } from './root';
import { NoteThread } from './thread';
import { NoteUser } from './user'; import { NoteUser } from './user';
import { NoteZap } from './zap';
export const Note = { export const Note = {
Root: NoteRoot, Root: NoteRoot,
User: NoteUser, User: NoteUser,
Menu: NoteMenu, Menu: NoteMenu,
Kind: NoteKind,
Reply: NoteReply, Reply: NoteReply,
Repost: NoteRepost, Repost: NoteRepost,
Reaction: NoteReaction, Reaction: NoteReaction,
Zap: NoteZap, Zap: NoteZap,
Pin: NotePin, Pin: NotePin,
Child: NoteChild, Child: NoteChild,
Thread: NoteThread,
TextContent: NoteTextContent,
MediaContent: NoteMediaContent,
ArticleContent: NoteArticleContent,
Replies: NoteReplies,
}; };
export * from './builds/text';
export * from './builds/repost';
export * from './builds/skeleton';
export * from './preview/image';
export * from './preview/link';
export * from './preview/video';
export * from './mentions/note';
export * from './mentions/user';
export * from './mentions/hashtag';
export * from './mentions/invoice';

View File

@@ -1,8 +1,13 @@
import { NDKTag } from '@nostr-dev-kit/ndk'; import { NDKTag } from '@nostr-dev-kit/ndk';
import { memo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
export function ArticleKind({ id, tags }: { id: string; tags: NDKTag[] }) { export function NoteArticleContent({
eventId,
tags,
}: {
eventId: string;
tags: NDKTag[];
}) {
const getMetadata = () => { const getMetadata = () => {
const title = tags.find((tag) => tag[0] === 'title')?.[1]; const title = tags.find((tag) => tag[0] === 'title')?.[1];
const image = tags.find((tag) => tag[0] === 'image')?.[1]; const image = tags.find((tag) => tag[0] === 'image')?.[1];
@@ -26,7 +31,7 @@ export function ArticleKind({ id, tags }: { id: string; tags: NDKTag[] }) {
return ( return (
<Link <Link
to={`/notes/article/${id}`} to={`/events/${eventId}`}
preventScrollReset={true} preventScrollReset={true}
className="flex w-full flex-col rounded-lg border border-neutral-200 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-900" className="flex w-full flex-col rounded-lg border border-neutral-200 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-900"
> >
@@ -56,5 +61,3 @@ export function ArticleKind({ id, tags }: { id: string; tags: NDKTag[] }) {
</Link> </Link>
); );
} }
export const MemoizedArticleKind = memo(ArticleKind);

View File

@@ -6,12 +6,18 @@ import {
DefaultVideoLayout, DefaultVideoLayout,
defaultLayoutIcons, defaultLayoutIcons,
} from '@vidstack/react/player/layouts/default'; } from '@vidstack/react/player/layouts/default';
import { memo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { DownloadIcon } from '@shared/icons'; import { DownloadIcon } from '@shared/icons';
import { fileType } from '@utils/nip94'; import { fileType } from '@utils/nip94';
export function FileKind({ tags }: { tags: NDKTag[] }) { export function NoteMediaContent({
tags,
className,
}: {
tags: NDKTag[];
className?: string;
}) {
const url = tags.find((el) => el[0] === 'url')[1]; const url = tags.find((el) => el[0] === 'url')[1];
const type = fileType(url); const type = fileType(url);
@@ -23,7 +29,7 @@ export function FileKind({ tags }: { tags: NDKTag[] }) {
if (type === 'image') { if (type === 'image') {
return ( return (
<div key={url} className="group relative"> <div key={url} className={twMerge('group relative', className)}>
<img <img
src={url} src={url}
alt={url} alt={url}
@@ -45,28 +51,30 @@ export function FileKind({ tags }: { tags: NDKTag[] }) {
if (type === 'video') { if (type === 'video') {
return ( return (
<MediaPlayer <div className={className}>
src={url} <MediaPlayer
className="w-full overflow-hidden rounded-lg" src={url}
aspectRatio="16/9" className="w-full overflow-hidden rounded-lg"
load="visible" aspectRatio="16/9"
> load="visible"
<MediaProvider /> >
<DefaultVideoLayout icons={defaultLayoutIcons} /> <MediaProvider />
</MediaPlayer> <DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
</div>
); );
} }
return ( return (
<Link <div className={className}>
to={url} <Link
target="_blank" to={url}
rel="noreferrer" target="_blank"
className="text-blue-500 hover:text-blue-600" rel="noreferrer"
> className="text-blue-500 hover:text-blue-600"
{url} >
</Link> {url}
</Link>
</div>
); );
} }
export const MemoizedFileKind = memo(FileKind);

View File

@@ -1,7 +1,7 @@
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { useRichContent } from '@utils/hooks/useRichContent'; import { useRichContent } from '@libs/ark';
export function NoteKind({ export function NoteTextContent({
content, content,
className, className,
}: { }: {
@@ -13,7 +13,7 @@ export function NoteKind({
return ( return (
<div <div
className={twMerge( className={twMerge(
'break-p select-text whitespace-pre-line leading-normal', 'break-p select-text whitespace-pre-line text-balance leading-normal',
className className
)} )}
> >

View File

@@ -1,5 +1,5 @@
import { useWidget } from '@libs/ark/hooks/useWidget';
import { WIDGET_KIND } from '@utils/constants'; import { WIDGET_KIND } from '@utils/constants';
import { useWidget } from '@utils/hooks/useWidget';
export function Hashtag({ tag }: { tag: string }) { export function Hashtag({ tag }: { tag: string }) {
const { addWidget } = useWidget(); const { addWidget } = useWidget();

View File

@@ -0,0 +1,63 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { memo } from 'react';
import { useEvent, useWidget } from '@libs/ark';
import { WIDGET_KIND } from '@utils/constants';
import { Note } from '..';
export const MentionNote = memo(function MentionNote({ eventId }: { eventId: string }) {
const { isLoading, isError, data } = useEvent(eventId);
const { addWidget } = useWidget();
const renderKind = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <Note.TextContent content={event.content} />;
case NDKKind.Article:
return <Note.ArticleContent eventId={event.id} tags={event.tags} />;
case 1063:
return <Note.MediaContent tags={event.tags} />;
default:
return <Note.TextContent content={event.content} />;
}
};
if (isLoading) {
return (
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
Loading
</div>
);
}
if (isError) {
return (
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
Failed to fetch event
</div>
);
}
return (
<Note.Root className="my-2 flex w-full cursor-default flex-col gap-1 rounded-lg bg-neutral-100 dark:bg-neutral-900">
<div className="mt-3 px-3">
<Note.User pubkey={data.pubkey} time={data.created_at} variant="mention" />
</div>
<div className="mt-1 px-3 pb-3">
{renderKind(data)}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: data.id,
})
}
className="mt-2 text-blue-500 hover:text-blue-600"
>
Show more
</button>
</div>
</Note.Root>
);
});

View File

@@ -1,7 +1,6 @@
import { memo } from 'react'; import { memo } from 'react';
import { useProfile, useWidget } from '@libs/ark';
import { WIDGET_KIND } from '@utils/constants'; import { WIDGET_KIND } from '@utils/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { useWidget } from '@utils/hooks/useWidget';
export const MentionUser = memo(function MentionUser({ pubkey }: { pubkey: string }) { export const MentionUser = memo(function MentionUser({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey); const { user } = useProfile(pubkey);

View File

@@ -1,43 +1,67 @@
import * as Tooltip from '@radix-ui/react-tooltip'; import { NDKSubscription } from '@nostr-dev-kit/ndk';
import { createSearchParams, useNavigate } from 'react-router-dom'; import { useEffect, useState } from 'react';
import { ReplyIcon } from '@shared/icons'; import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { NDKEventWithReplies } from '@utils/types';
import { Reply } from './builds/reply';
export function NoteReply({ export function NoteReplies({ eventId }: { eventId: string }) {
eventId, const ark = useArk();
rootEventId, const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
}: {
eventId: string; useEffect(() => {
rootEventId?: string; let sub: NDKSubscription;
}) { let isCancelled = false;
const navigate = useNavigate();
async function fetchRepliesAndSub() {
const events = await ark.getThreads({ id: eventId });
if (!isCancelled) {
setData(events);
}
// subscribe for new replies
sub = ark.subscribe({
filter: {
'#e': [eventId],
since: Math.floor(Date.now() / 1000),
},
closeOnEose: false,
cb: (event: NDKEventWithReplies) => setData((prev) => [event, ...prev]),
});
}
fetchRepliesAndSub();
return () => {
isCancelled = true;
if (sub) sub.stop();
};
}, [eventId]);
if (!data) {
return (
<div className="mt-3">
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
</div>
);
}
return ( return (
<Tooltip.Provider> <div className="mt-3 flex flex-col gap-5">
<Tooltip.Root delayDuration={150}> <h3 className="font-semibold">Replies</h3>
<Tooltip.Trigger asChild> {data?.length === 0 ? (
<button <div className="mt-2 flex w-full items-center justify-center">
type="button" <div className="flex flex-col items-center justify-center gap-2 py-6">
onClick={() => <h3 className="text-3xl">👋</h3>
navigate({ <p className="leading-none text-neutral-600 dark:text-neutral-400">
pathname: '/new/', Be the first to Reply!
search: createSearchParams({ </p>
replyTo: eventId, </div>
rootReplyTo: rootEventId, </div>
}).toString(), ) : (
}) data.map((event) => <Reply key={event.id} event={event} rootEvent={eventId} />)
} )}
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400" </div>
>
<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

@@ -9,10 +9,13 @@ export function NoteRoot({
className?: string; className?: string;
}) { }) {
return ( return (
<div className={twMerge('h-min w-full p-3', className)}> <div
<div className="relative flex flex-col overflow-hidden rounded-xl bg-neutral-50 dark:bg-neutral-950"> className={twMerge(
{children} 'mt-3 flex h-min w-full flex-col overflow-hidden rounded-xl bg-neutral-50 px-3 dark:bg-neutral-950',
</div> className
)}
>
{children}
</div> </div>
); );
} }

View File

@@ -0,0 +1,38 @@
import { twMerge } from 'tailwind-merge';
import { useWidget } from '@libs/ark';
import { WIDGET_KIND } from '@utils/constants';
import { Note } from '.';
export function NoteThread({
thread,
className,
}: {
thread: { rootEventId: string; replyEventId: string };
className?: string;
}) {
const { addWidget } = useWidget();
if (!thread) return null;
return (
<div className={twMerge('w-full px-3', className)}>
<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"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: thread.rootEventId,
})
}
className="self-start text-blue-500 hover:text-blue-600"
>
Show full thread
</button>
</div>
</div>
);
}

View File

@@ -1,9 +1,8 @@
import * as Avatar from '@radix-ui/react-avatar'; import * as Avatar from '@radix-ui/react-avatar';
import { useQuery } from '@tanstack/react-query';
import { minidenticon } from 'minidenticons'; import { minidenticon } from 'minidenticons';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { useArk } from '@libs/ark'; import { useProfile } from '@libs/ark';
import { RepostIcon } from '@shared/icons'; import { RepostIcon } from '@shared/icons';
import { displayNpub, formatCreatedAt } from '@utils/formater'; import { displayNpub, formatCreatedAt } from '@utils/formater';
@@ -15,10 +14,9 @@ export function NoteUser({
}: { }: {
pubkey: string; pubkey: string;
time: number; time: number;
variant?: 'text' | 'repost'; variant?: 'text' | 'repost' | 'mention';
className?: string; className?: string;
}) { }) {
const ark = useArk();
const createdAt = useMemo(() => formatCreatedAt(time), [time]); const createdAt = useMemo(() => formatCreatedAt(time), [time]);
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]); const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo( const fallbackAvatar = useMemo(
@@ -26,27 +24,58 @@ export function NoteUser({
[pubkey] [pubkey]
); );
const { isLoading, data: user } = useQuery({ const { isLoading, user } = useProfile(pubkey);
queryKey: ['user', pubkey],
queryFn: async () => {
try {
const profile = await ark.getUserProfile({ pubkey });
if (!profile) if (variant === 'mention') {
throw new Error( if (isLoading) {
`Cannot get metadata for ${pubkey}, will be retry after 10 seconds` return (
); <div className="flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
</div>
</div>
);
}
return profile; return (
} catch (e) { <div className="flex h-6 items-center gap-2">
throw new Error(e); <Avatar.Root className="shrink-0">
} <Avatar.Image
}, src={user?.picture || user?.image}
refetchOnMount: false, alt={pubkey}
refetchOnWindowFocus: false, loading="lazy"
refetchOnReconnect: false, decoding="async"
retry: 2, className="h-6 w-6 rounded-md"
}); />
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
</div>
</div>
);
}
if (variant === 'repost') { if (variant === 'repost') {
if (isLoading) { if (isLoading) {

View File

@@ -1,6 +1,7 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { useWidget } from '@libs/ark';
import { import {
ArrowLeftIcon, ArrowLeftIcon,
ArrowRightIcon, ArrowRightIcon,
@@ -9,7 +10,6 @@ import {
ThreadIcon, ThreadIcon,
TrashIcon, TrashIcon,
} from '@shared/icons'; } from '@shared/icons';
import { useWidget } from '@utils/hooks/useWidget';
export function WidgetHeader({ export function WidgetHeader({
id, id,

View File

@@ -1,9 +1,11 @@
import { WidgetContent } from './content'; import { WidgetContent } from './content';
import { WidgetHeader } from './header'; import { WidgetHeader } from './header';
import { WidgetLive } from './live';
import { WidgetRoot } from './root'; import { WidgetRoot } from './root';
export const Widget = { export const Widget = {
Root: WidgetRoot, Root: WidgetRoot,
Live: WidgetLive,
Header: WidgetHeader, Header: WidgetHeader,
Content: WidgetContent, Content: WidgetContent,
}; };

View File

@@ -0,0 +1,42 @@
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useArk } from '@libs/ark/provider';
import { ChevronUpIcon } from '@shared/icons';
export function WidgetLive({
filter,
onClick,
}: {
filter: NDKFilter;
onClick: () => void;
}) {
const ark = useArk();
const [events, setEvents] = useState<NDKEvent[]>([]);
useEffect(() => {
const sub = ark.subscribe({
filter,
closeOnEose: false,
cb: (event: NDKEvent) => setEvents((prev) => [...prev, event]),
});
return () => {
if (sub) sub.stop();
};
}, []);
if (!events.length) return null;
return (
<div className="absolute left-0 top-11 z-50 flex h-11 w-full items-center justify-center">
<button
type="button"
onClick={onClick}
className="inline-flex h-9 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-2.5 text-sm font-semibold text-white hover:bg-blue-600"
>
<ChevronUpIcon className="h-4 w-4" />
{events.length} {events.length === 1 ? 'event' : 'events'}
</button>
</div>
);
}

View File

@@ -1,24 +1,14 @@
import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useArk } from '@libs/ark'; import { useArk } from '@libs/ark';
export function useEvent(id: undefined | string, embed?: undefined | string) { export function useEvent(id: string) {
const ark = useArk(); const ark = useArk();
const { status, isFetching, isError, data } = useQuery({ const { status, isLoading, isError, data } = useQuery({
queryKey: ['event', id], queryKey: ['event', id],
queryFn: async () => { queryFn: async () => {
// return embed event (nostr.band api)
if (embed) {
const embedEvent: NostrEvent = JSON.parse(embed);
return new NDKEvent(ark.ndk, embedEvent);
}
// get event from relay
const event = await ark.getEventById({ id }); const event = await ark.getEventById({ id });
if (!event) if (!event)
throw new Error(`Cannot get event with ${id}, will be retry after 10 seconds`); throw new Error(`Cannot get event with ${id}, will be retry after 10 seconds`);
return event; return event;
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
@@ -27,5 +17,5 @@ export function useEvent(id: undefined | string, embed?: undefined | string) {
retry: 2, retry: 2,
}); });
return { status, isFetching, isError, data }; return { status, isLoading, isError, data };
} }

View File

@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { useArk } from '@libs/ark';
export function useProfile(pubkey: string) {
const ark = useArk();
const {
isLoading,
isError,
data: user,
} = useQuery({
queryKey: ['user', pubkey],
queryFn: async () => {
const profile = await ark.getUserProfile({ pubkey });
if (!profile)
throw new Error(
`Cannot get metadata for ${pubkey}, will be retry after 10 seconds`
);
return profile;
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: 2,
});
return { isLoading, isError, user };
}

View File

@@ -3,7 +3,6 @@ import { nip19 } from 'nostr-tools';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import reactStringReplace from 'react-string-replace'; import reactStringReplace from 'react-string-replace';
import { useArk } from '@libs/ark';
import { import {
Hashtag, Hashtag,
ImagePreview, ImagePreview,
@@ -11,7 +10,8 @@ import {
MentionNote, MentionNote,
MentionUser, MentionUser,
VideoPreview, VideoPreview,
} from '@shared/notes'; useStorage,
} from '@libs/ark';
const NOSTR_MENTIONS = [ const NOSTR_MENTIONS = [
'@npub1', '@npub1',
@@ -54,7 +54,7 @@ const VIDEOS = [
]; ];
export function useRichContent(content: string, textmode: boolean = false) { export function useRichContent(content: string, textmode: boolean = false) {
const ark = useArk(); const storage = useStorage();
let parsedContent: string | ReactNode[] = content.replace(/\n+/g, '\n'); let parsedContent: string | ReactNode[] = content.replace(/\n+/g, '\n');
let linkPreview: string; let linkPreview: string;
@@ -66,7 +66,7 @@ export function useRichContent(content: string, textmode: boolean = false) {
const words = text.split(/( |\n)/); const words = text.split(/( |\n)/);
if (!textmode) { if (!textmode) {
if (ark.settings.media) { if (storage.settings.media) {
images = words.filter((word) => IMAGES.some((el) => word.endsWith(el))); images = words.filter((word) => IMAGES.some((el) => word.endsWith(el)));
videos = words.filter((word) => VIDEOS.some((el) => word.endsWith(el))); videos = words.filter((word) => VIDEOS.some((el) => word.endsWith(el)));
} }
@@ -98,7 +98,7 @@ export function useRichContent(content: string, textmode: boolean = false) {
if (hashtags.length) { if (hashtags.length) {
hashtags.forEach((hashtag) => { hashtags.forEach((hashtag) => {
parsedContent = reactStringReplace(parsedContent, hashtag, (match, i) => { parsedContent = reactStringReplace(parsedContent, hashtag, (match, i) => {
if (ark.settings.hashtag) return <Hashtag key={match + i} tag={hashtag} />; if (storage.settings.hashtag) return <Hashtag key={match + i} tag={hashtag} />;
return null; return null;
}); });
}); });
@@ -111,13 +111,13 @@ export function useRichContent(content: string, textmode: boolean = false) {
if (decoded.type === 'note') { if (decoded.type === 'note') {
parsedContent = reactStringReplace(parsedContent, event, (match, i) => ( parsedContent = reactStringReplace(parsedContent, event, (match, i) => (
<MentionNote key={match + i} id={decoded.data} /> <MentionNote key={match + i} eventId={decoded.data} />
)); ));
} }
if (decoded.type === 'nevent') { if (decoded.type === 'nevent') {
parsedContent = reactStringReplace(parsedContent, event, (match, i) => ( parsedContent = reactStringReplace(parsedContent, event, (match, i) => (
<MentionNote key={match + i} id={decoded.data.id} /> <MentionNote key={match + i} eventId={decoded.data.id} />
)); ));
} }
}); });

View File

@@ -1,22 +1,28 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useArk } from '@libs/ark'; import { useStorage } from '@libs/ark';
import { Widget } from '@utils/types'; import { WidgetProps } from '@utils/types';
export function useWidget() { export function useWidget() {
const ark = useArk(); const storage = useStorage();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const addWidget = useMutation({ const addWidget = useMutation({
mutationFn: async (widget: Widget) => { mutationFn: async (widget: WidgetProps) => {
return await ark.createWidget(widget.kind, widget.title, widget.content); return await storage.createWidget(widget.kind, widget.title, widget.content);
}, },
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(['widgets'], (old: Widget[]) => [...old, data]); queryClient.setQueryData(['widgets'], (old: WidgetProps[]) => [...old, data]);
}, },
}); });
const replaceWidget = useMutation({ const replaceWidget = useMutation({
mutationFn: async ({ currentId, widget }: { currentId: string; widget: Widget }) => { mutationFn: async ({
currentId,
widget,
}: {
currentId: string;
widget: WidgetProps;
}) => {
// Cancel any outgoing refetches // Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['widgets'] }); await queryClient.cancelQueries({ queryKey: ['widgets'] });
@@ -24,11 +30,15 @@ export function useWidget() {
const prevWidgets = queryClient.getQueryData(['widgets']); const prevWidgets = queryClient.getQueryData(['widgets']);
// create new widget // create new widget
await ark.removeWidget(currentId); await storage.removeWidget(currentId);
const newWidget = await ark.createWidget(widget.kind, widget.title, widget.content); const newWidget = await storage.createWidget(
widget.kind,
widget.title,
widget.content
);
// Optimistically update to the new value // Optimistically update to the new value
queryClient.setQueryData(['widgets'], (prev: Widget[]) => [ queryClient.setQueryData(['widgets'], (prev: WidgetProps[]) => [
...prev.filter((t) => t.id !== currentId), ...prev.filter((t) => t.id !== currentId),
newWidget, newWidget,
]); ]);
@@ -50,12 +60,12 @@ export function useWidget() {
const prevWidgets = queryClient.getQueryData(['widgets']); const prevWidgets = queryClient.getQueryData(['widgets']);
// Optimistically update to the new value // Optimistically update to the new value
queryClient.setQueryData(['widgets'], (prev: Widget[]) => queryClient.setQueryData(['widgets'], (prev: WidgetProps[]) =>
prev.filter((t) => t.id !== id) prev.filter((t) => t.id !== id)
); );
// Update in database // Update in database
await ark.removeWidget(id); await storage.removeWidget(id);
// Return a context object with the snapshotted value // Return a context object with the snapshotted value
return { prevWidgets }; return { prevWidgets };

View File

@@ -2,3 +2,7 @@ export * from './ark';
export * from './provider'; export * from './provider';
export * from './components/widget'; export * from './components/widget';
export * from './components/note'; export * from './components/note';
export * from './hooks/useWidget';
export * from './hooks/useRichContent';
export * from './hooks/useEvent';
export * from './hooks/useProfile';

View File

@@ -1,57 +1,174 @@
import { ask } from '@tauri-apps/plugin-dialog'; import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
import { platform } from '@tauri-apps/plugin-os'; import { platform } from '@tauri-apps/plugin-os';
import { relaunch } from '@tauri-apps/plugin-process'; import { relaunch } from '@tauri-apps/plugin-process';
import Database from '@tauri-apps/plugin-sql'; import Database from '@tauri-apps/plugin-sql';
import { check } from '@tauri-apps/plugin-updater'; import { check } from '@tauri-apps/plugin-updater';
import Markdown from 'markdown-to-jsx'; import Markdown from 'markdown-to-jsx';
import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react'; import { NostrFetcher, normalizeRelayUrl, normalizeRelayUrlSet } from 'nostr-fetch';
import { PropsWithChildren, useEffect, useState } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';
import { Ark } from '@libs/ark'; import { Ark } from '@libs/ark';
import { NDKCacheAdapterTauri } from '@libs/cache';
import { LumeStorage } from '@libs/storage';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { QUOTES } from '@utils/constants'; import { QUOTES } from '@utils/constants';
import { delay } from '@utils/delay';
const ArkContext = createContext<Ark>(undefined); type Context = {
storage: LumeStorage;
ark: Ark;
};
const ArkProvider = ({ children }: PropsWithChildren<object>) => { const LumeContext = createContext<Context>({
const [ark, setArk] = useState<Ark>(undefined); storage: undefined,
ark: undefined,
});
const LumeProvider = ({ children }: PropsWithChildren<object>) => {
const [context, setContext] = useState<Context>(undefined);
const [isNewVersion, setIsNewVersion] = useState(false); const [isNewVersion, setIsNewVersion] = useState(false);
async function initArk() { async function initNostrSigner({
storage,
nsecbunker,
}: {
storage: LumeStorage;
nsecbunker?: boolean;
}) {
try { try {
const sqlite = await Database.load('sqlite:lume_v2.db'); if (!storage.account) return null;
const platformName = await platform();
const _ark = new Ark({ storage: sqlite, platform: platformName }); // NIP-46 Signer
await _ark.init(); if (nsecbunker) {
const localSignerPrivkey = await storage.loadPrivkey(
`${storage.account.id}-nsecbunker`
);
// check update if (!localSignerPrivkey) return null;
if (_ark.settings.autoupdate) {
const update = await check();
// install new version
if (update) {
setIsNewVersion(true);
await update.downloadAndInstall(); const localSigner = new NDKPrivateKeySigner(localSignerPrivkey);
await relaunch(); const bunker = new NDK({
} explicitRelayUrls: normalizeRelayUrlSet([
'wss://relay.nsecbunker.com/',
'wss://nostr.vulpem.com/',
]),
});
await bunker.connect(3000);
const remoteSigner = new NDKNip46Signer(
bunker,
storage.account.pubkey,
localSigner
);
await remoteSigner.blockUntilReady();
return remoteSigner;
} }
setArk(_ark); // Privkey Signer
const userPrivkey = await storage.loadPrivkey(storage.account.pubkey);
if (!userPrivkey) {
return null;
}
return new NDKPrivateKeySigner(userPrivkey);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
const yes = await ask(`${e}. Click "Yes" to relaunch app`, { return null;
title: 'Lume',
type: 'error',
okLabel: 'Yes',
});
if (yes) relaunch();
} }
} }
async function init() {
const platformName = await platform();
const sqliteAdapter = await Database.load('sqlite:lume_v2.db');
const storage = new LumeStorage(sqliteAdapter, platformName);
storage.init();
// check for new update
if (storage.settings.autoupdate) {
const update = await check();
// install new version
if (update) {
setIsNewVersion(true);
await update.downloadAndInstall();
await relaunch();
}
}
const explicitRelayUrls = normalizeRelayUrlSet([
'wss://relay.damus.io',
'wss://relay.nostr.band/all',
'wss://nostr.mutinywallet.com',
]);
if (storage.settings.depot) {
await storage.launchDepot();
await delay(2000);
explicitRelayUrls.push(normalizeRelayUrl('ws://localhost:6090'));
}
// #TODO: user should config outbox relays
const outboxRelayUrls = normalizeRelayUrlSet(['wss://purplepag.es']);
// #TODO: user should config blacklist relays
// No need to connect depot tunnel url
const blacklistRelayUrls = storage.settings.tunnelUrl.length
? [storage.settings.tunnelUrl, storage.settings.tunnelUrl + '/']
: [];
const cacheAdapter = new NDKCacheAdapterTauri(storage);
const ndk = new NDK({
cacheAdapter,
explicitRelayUrls,
outboxRelayUrls,
blacklistRelayUrls,
enableOutboxModel: storage.settings.lowPowerMode ? false : storage.settings.outbox,
autoConnectUserRelays: storage.settings.lowPowerMode ? false : true,
autoFetchUserMutelist: storage.settings.lowPowerMode ? false : true,
// clientName: 'Lume',
// clientNip89: '',
});
// add signer
const signer = await initNostrSigner({
storage,
nsecbunker: storage.settings.bunker,
});
if (signer) ndk.signer = signer;
// connect
await ndk.connect(3000);
// update account's metadata
if (storage.account) {
const user = ndk.getUser({ pubkey: storage.account.pubkey });
ndk.activeUser = user;
const contacts = await user.follows();
storage.account.contacts = [...contacts].map((user) => user.pubkey);
}
// init nostr fetcher
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(ndk));
// ark utils
const ark = new Ark({ storage, ndk, fetcher });
// update context
setContext({ ark, storage });
}
useEffect(() => { useEffect(() => {
if (!ark && !isNewVersion) initArk(); if (!context && !isNewVersion) init();
}, []); }, []);
if (!ark) { if (!context) {
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
@@ -85,15 +202,27 @@ const ArkProvider = ({ children }: PropsWithChildren<object>) => {
); );
} }
return <ArkContext.Provider value={ark}>{children}</ArkContext.Provider>; return (
<LumeContext.Provider value={{ ark: context.ark, storage: context.storage }}>
{children}
</LumeContext.Provider>
);
}; };
const useArk = () => { const useArk = () => {
const context = useContext(ArkContext); const context = useContextSelector(LumeContext, (state) => state.ark);
if (context === undefined) { if (context === undefined) {
throw new Error('Please import Ark Provider to use useArk() hook'); throw new Error('Please import Ark Provider to use useArk() hook');
} }
return context; return context;
}; };
export { ArkProvider, useArk }; const useStorage = () => {
const context = useContextSelector(LumeContext, (state) => state.storage);
if (context === undefined) {
throw new Error('Please import Ark Provider to use useStorage() hook');
}
return context;
};
export { LumeProvider, useArk, useStorage };

View File

@@ -10,20 +10,19 @@ import {
NDKUserProfile, NDKUserProfile,
profileFromEvent, profileFromEvent,
} from '@nostr-dev-kit/ndk'; } from '@nostr-dev-kit/ndk';
import Database from '@tauri-apps/plugin-sql';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { NostrEvent } from 'nostr-fetch'; import { NostrEvent } from 'nostr-fetch';
import { matchFilter } from 'nostr-tools'; import { matchFilter } from 'nostr-tools';
import { NDKCacheEvent, NDKCacheEventTag, NDKCacheUser } from '@utils/types'; import { LumeStorage } from '@libs/storage';
export class NDKCacheAdapterTauri implements NDKCacheAdapter { export class NDKCacheAdapterTauri implements NDKCacheAdapter {
#db: Database; #storage: LumeStorage;
private dirtyProfiles: Set<Hexpubkey> = new Set(); private dirtyProfiles: Set<Hexpubkey> = new Set();
public profiles?: LRUCache<Hexpubkey, NDKUserProfile>; public profiles?: LRUCache<Hexpubkey, NDKUserProfile>;
readonly locking: boolean; readonly locking: boolean;
constructor(db: Database) { constructor(storage: LumeStorage) {
this.#db = db; this.#storage = storage;
this.locking = true; this.locking = true;
this.profiles = new LRUCache({ this.profiles = new LRUCache({
@@ -35,115 +34,6 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
}, 1000 * 10); }, 1000 * 10);
} }
async #getCacheUser(pubkey: string) {
const results: Array<NDKCacheUser> = await this.#db.select(
'SELECT * FROM ndk_users WHERE pubkey = $1 ORDER BY pubkey DESC LIMIT 1;',
[pubkey]
);
if (!results.length) return null;
if (typeof results[0].profile === 'string')
results[0].profile = JSON.parse(results[0].profile);
return results[0];
}
async #getCacheEvent(id: string) {
const results: Array<NDKCacheEvent> = await this.#db.select(
'SELECT * FROM ndk_events WHERE id = $1 ORDER BY id DESC LIMIT 1;',
[id]
);
if (!results.length) return null;
return results[0];
}
async #getCacheEvents(ids: string[]) {
const idsArr = `'${ids.join("','")}'`;
const results: Array<NDKCacheEvent> = await this.#db.select(
`SELECT * FROM ndk_events WHERE id IN (${idsArr}) ORDER BY id;`
);
if (!results.length) return [];
return results;
}
async #getCacheEventsByPubkey(pubkey: string) {
const results: Array<NDKCacheEvent> = await this.#db.select(
'SELECT * FROM ndk_events WHERE pubkey = $1 ORDER BY id;',
[pubkey]
);
if (!results.length) return [];
return results;
}
async #getCacheEventsByKind(kind: number) {
const results: Array<NDKCacheEvent> = await this.#db.select(
'SELECT * FROM ndk_events WHERE kind = $1 ORDER BY id;',
[kind]
);
if (!results.length) return [];
return results;
}
async #getCacheEventsByKindAndAuthor(kind: number, pubkey: string) {
const results: Array<NDKCacheEvent> = await this.#db.select(
'SELECT * FROM ndk_events WHERE kind = $1 AND pubkey = $2 ORDER BY id;',
[kind, pubkey]
);
if (!results.length) return [];
return results;
}
async #getCacheEventTagsByTagValue(tagValue: string) {
const results: Array<NDKCacheEventTag> = await this.#db.select(
'SELECT * FROM ndk_eventtags WHERE tagValue = $1 ORDER BY id;',
[tagValue]
);
if (!results.length) return [];
return results;
}
async #setCacheEvent({
id,
pubkey,
content,
kind,
createdAt,
relay,
event,
}: NDKCacheEvent) {
return await this.#db.execute(
'INSERT OR IGNORE INTO ndk_events (id, pubkey, content, kind, createdAt, relay, event) VALUES ($1, $2, $3, $4, $5, $6, $7);',
[id, pubkey, content, kind, createdAt, relay, event]
);
}
async #setCacheEventTag({ id, eventId, tag, value, tagValue }: NDKCacheEventTag) {
return await this.#db.execute(
'INSERT OR IGNORE INTO ndk_eventtags (id, eventId, tag, value, tagValue) VALUES ($1, $2, $3, $4, $5);',
[id, eventId, tag, value, tagValue]
);
}
async #setCacheProfiles(profiles: Array<NDKCacheUser>) {
return await Promise.all(
profiles.map(
async (profile) =>
await this.#db.execute(
'INSERT OR IGNORE INTO ndk_users (pubkey, profile, createdAt) VALUES ($1, $2, $3);',
[profile.pubkey, profile.profile, profile.createdAt]
)
)
);
}
public async query(subscription: NDKSubscription): Promise<void> { public async query(subscription: NDKSubscription): Promise<void> {
Promise.allSettled( Promise.allSettled(
subscription.filters.map((filter) => this.processFilter(filter, subscription)) subscription.filters.map((filter) => this.processFilter(filter, subscription))
@@ -156,7 +46,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
let profile = this.profiles.get(pubkey); let profile = this.profiles.get(pubkey);
if (!profile) { if (!profile) {
const user = await this.#getCacheUser(pubkey); const user = await this.#storage.getCacheUser(pubkey);
if (user) { if (user) {
profile = user.profile as NDKUserProfile; profile = user.profile as NDKUserProfile;
this.profiles.set(pubkey, profile); this.profiles.set(pubkey, profile);
@@ -211,7 +101,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
if (event.isParamReplaceable()) { if (event.isParamReplaceable()) {
const replaceableId = `${event.kind}:${event.pubkey}:${event.tagId()}`; const replaceableId = `${event.kind}:${event.pubkey}:${event.tagId()}`;
const existingEvent = await this.#getCacheEvent(replaceableId); const existingEvent = await this.#storage.getCacheEvent(replaceableId);
if ( if (
existingEvent && existingEvent &&
event.created_at && event.created_at &&
@@ -222,7 +112,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
} }
if (addEvent) { if (addEvent) {
this.#setCacheEvent({ this.#storage.setCacheEvent({
id: event.tagId(), id: event.tagId(),
pubkey: event.pubkey, pubkey: event.pubkey,
content: event.content, content: event.content,
@@ -238,7 +128,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
event.tags.forEach((tag) => { event.tags.forEach((tag) => {
if (tag[0].length !== 1) return; if (tag[0].length !== 1) return;
this.#setCacheEventTag({ this.#storage.setCacheEventTag({
id: `${event.id}:${tag[0]}:${tag[1]}`, id: `${event.id}:${tag[0]}:${tag[1]}`,
eventId: event.id, eventId: event.id,
tag: tag[0], tag: tag[0],
@@ -267,7 +157,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
if (hasAllKeys && filter.authors) { if (hasAllKeys && filter.authors) {
for (const pubkey of filter.authors) { for (const pubkey of filter.authors) {
const events = await this.#getCacheEventsByPubkey(pubkey); const events = await this.#storage.getCacheEventsByPubkey(pubkey);
for (const event of events) { for (const event of events) {
let rawEvent: NostrEvent; let rawEvent: NostrEvent;
try { try {
@@ -303,7 +193,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
if (hasAllKeys && filter.kinds) { if (hasAllKeys && filter.kinds) {
for (const kind of filter.kinds) { for (const kind of filter.kinds) {
const events = await this.#getCacheEventsByKind(kind); const events = await this.#storage.getCacheEventsByKind(kind);
for (const event of events) { for (const event of events) {
let rawEvent: NostrEvent; let rawEvent: NostrEvent;
try { try {
@@ -337,7 +227,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
if (hasAllKeys && filter.ids) { if (hasAllKeys && filter.ids) {
for (const id of filter.ids) { for (const id of filter.ids) {
const event = await this.#getCacheEvent(id); const event = await this.#storage.getCacheEvent(id);
if (!event) continue; if (!event) continue;
let rawEvent: NostrEvent; let rawEvent: NostrEvent;
@@ -380,7 +270,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
for (const author of filter.authors) { for (const author of filter.authors) {
for (const dTag of filter['#d']) { for (const dTag of filter['#d']) {
const replaceableId = `${kind}:${author}:${dTag}`; const replaceableId = `${kind}:${author}:${dTag}`;
const event = await this.#getCacheEvent(replaceableId); const event = await this.#storage.getCacheEvent(replaceableId);
if (!event) continue; if (!event) continue;
let rawEvent: NostrEvent; let rawEvent: NostrEvent;
@@ -420,7 +310,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
if (filter.kinds && filter.authors) { if (filter.kinds && filter.authors) {
for (const kind of filter.kinds) { for (const kind of filter.kinds) {
for (const author of filter.authors) { for (const author of filter.authors) {
const events = await this.#getCacheEventsByKindAndAuthor(kind, author); const events = await this.#storage.getCacheEventsByKindAndAuthor(kind, author);
for (const event of events) { for (const event of events) {
let rawEvent: NostrEvent; let rawEvent: NostrEvent;
@@ -485,12 +375,12 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
} }
for (const value of values) { for (const value of values) {
const eventTags = await this.#getCacheEventTagsByTagValue(tag + value); const eventTags = await this.#storage.getCacheEventTagsByTagValue(tag + value);
if (!eventTags.length) continue; if (!eventTags.length) continue;
const eventIds = eventTags.map((t) => t.eventId); const eventIds = eventTags.map((t) => t.eventId);
const events = await this.#getCacheEvents(eventIds); const events = await this.#storage.getCacheEvents(eventIds);
for (const event of events) { for (const event of events) {
let rawEvent; let rawEvent;
try { try {
@@ -532,7 +422,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
} }
if (profiles.length) { if (profiles.length) {
await this.#setCacheProfiles(profiles); await this.#storage.setCacheProfiles(profiles);
} }
this.dirtyProfiles.clear(); this.dirtyProfiles.clear();

396
src/libs/storage/index.ts Normal file
View File

@@ -0,0 +1,396 @@
import { appConfigDir, resolveResource } from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api/primitives';
import { Platform } from '@tauri-apps/plugin-os';
import { Child, Command } from '@tauri-apps/plugin-shell';
import Database from '@tauri-apps/plugin-sql';
import {
Account,
NDKCacheEvent,
NDKCacheEventTag,
NDKCacheUser,
NDKCacheUserProfile,
WidgetProps,
} from '@utils/types';
export class LumeStorage {
#db: Database;
#depot: Child;
readonly platform: Platform;
public account: Account;
public settings: {
autoupdate: boolean;
bunker: boolean;
outbox: boolean;
media: boolean;
hashtag: boolean;
depot: boolean;
tunnelUrl: string;
lowPowerMode: boolean;
};
constructor(db: Database, platform: Platform) {
this.#db = db;
this.#depot = undefined;
this.platform = platform;
this.settings = {
autoupdate: false,
bunker: false,
outbox: false,
media: true,
hashtag: true,
depot: false,
tunnelUrl: '',
lowPowerMode: false,
};
}
public async init() {
const settings = await this.getAllSettings();
for (const item of settings) {
if (item.key === 'nsecbunker') this.settings.bunker = !!parseInt(item.value);
if (item.key === 'outbox') this.settings.outbox = !!parseInt(item.value);
if (item.key === 'media') this.settings.media = !!parseInt(item.value);
if (item.key === 'hashtag') this.settings.hashtag = !!parseInt(item.value);
if (item.key === 'autoupdate') this.settings.autoupdate = !!parseInt(item.value);
if (item.key === 'depot') this.settings.depot = !!parseInt(item.value);
if (item.key === 'tunnel_url') this.settings.tunnelUrl = item.value;
}
const account = await this.getActiveAccount();
if (account) this.account = account;
}
async #keyring_save(key: string, value: string) {
return await invoke('secure_save', { key, value });
}
async #keyring_load(key: string) {
try {
const value: string = await invoke('secure_load', { key });
if (!value) return null;
return value;
} catch {
return null;
}
}
async #keyring_remove(key: string) {
return await invoke('secure_remove', { key });
}
public async launchDepot() {
const configPath = await resolveResource('resources/config.toml');
const dataPath = await appConfigDir();
const command = Command.sidecar('bin/depot', ['-c', configPath, '-d', dataPath]);
this.#depot = await command.spawn();
}
public checkDepot() {
if (this.#depot) return true;
return false;
}
public async stopDepot() {
if (this.#depot) return this.#depot.kill();
}
public async getCacheUser(pubkey: string) {
const results: Array<NDKCacheUser> = await this.#db.select(
'SELECT * FROM ndk_users WHERE pubkey = $1 ORDER BY pubkey DESC LIMIT 1;',
[pubkey]
);
if (!results.length) return null;
if (typeof results[0].profile === 'string')
results[0].profile = JSON.parse(results[0].profile);
return results[0];
}
public async getCacheEvent(id: string) {
const results: Array<NDKCacheEvent> = await this.#db.select(
'SELECT * FROM ndk_events WHERE id = $1 ORDER BY id DESC LIMIT 1;',
[id]
);
if (!results.length) return null;
return results[0];
}
public async getCacheEvents(ids: string[]) {
const idsArr = `'${ids.join("','")}'`;
const results: Array<NDKCacheEvent> = await this.#db.select(
`SELECT * FROM ndk_events WHERE id IN (${idsArr}) ORDER BY id;`
);
if (!results.length) return [];
return results;
}
public async getCacheEventsByPubkey(pubkey: string) {
const results: Array<NDKCacheEvent> = await this.#db.select(
'SELECT * FROM ndk_events WHERE pubkey = $1 ORDER BY id;',
[pubkey]
);
if (!results.length) return [];
return results;
}
public async getCacheEventsByKind(kind: number) {
const results: Array<NDKCacheEvent> = await this.#db.select(
'SELECT * FROM ndk_events WHERE kind = $1 ORDER BY id;',
[kind]
);
if (!results.length) return [];
return results;
}
public async getCacheEventsByKindAndAuthor(kind: number, pubkey: string) {
const results: Array<NDKCacheEvent> = await this.#db.select(
'SELECT * FROM ndk_events WHERE kind = $1 AND pubkey = $2 ORDER BY id;',
[kind, pubkey]
);
if (!results.length) return [];
return results;
}
public async getCacheEventTagsByTagValue(tagValue: string) {
const results: Array<NDKCacheEventTag> = await this.#db.select(
'SELECT * FROM ndk_eventtags WHERE tagValue = $1 ORDER BY id;',
[tagValue]
);
if (!results.length) return [];
return results;
}
public async setCacheEvent({
id,
pubkey,
content,
kind,
createdAt,
relay,
event,
}: NDKCacheEvent) {
return await this.#db.execute(
'INSERT OR IGNORE INTO ndk_events (id, pubkey, content, kind, createdAt, relay, event) VALUES ($1, $2, $3, $4, $5, $6, $7);',
[id, pubkey, content, kind, createdAt, relay, event]
);
}
public async setCacheEventTag({ id, eventId, tag, value, tagValue }: NDKCacheEventTag) {
return await this.#db.execute(
'INSERT OR IGNORE INTO ndk_eventtags (id, eventId, tag, value, tagValue) VALUES ($1, $2, $3, $4, $5);',
[id, eventId, tag, value, tagValue]
);
}
public async setCacheProfiles(profiles: Array<NDKCacheUser>) {
return await Promise.all(
profiles.map(
async (profile) =>
await this.#db.execute(
'INSERT OR IGNORE INTO ndk_users (pubkey, profile, createdAt) VALUES ($1, $2, $3);',
[profile.pubkey, profile.profile, profile.createdAt]
)
)
);
}
public async getAllCacheUsers() {
const results: Array<NDKCacheUser> = await this.#db.select(
'SELECT * FROM ndk_users ORDER BY createdAt DESC;'
);
if (!results.length) return [];
const users: NDKCacheUserProfile[] = results.map((item) => ({
pubkey: item.pubkey,
...JSON.parse(item.profile as string),
}));
return users;
}
public async checkAccount() {
const result: Array<{ total: string }> = await this.#db.select(
'SELECT COUNT(*) AS "total" FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
);
return parseInt(result[0].total);
}
public async getActiveAccount() {
const results: Array<Account> = await this.#db.select(
'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
);
if (results.length) {
this.account = results[0];
return results[0];
} else {
return null;
}
}
public async createAccount({
id,
pubkey,
privkey,
}: {
id: string;
pubkey: string;
privkey?: string;
}) {
const existAccounts: Array<Account> = await this.#db.select(
'SELECT * FROM accounts WHERE pubkey = $1 ORDER BY id DESC LIMIT 1;',
[pubkey]
);
if (existAccounts.length) {
await this.#db.execute("UPDATE accounts SET is_active = '1' WHERE pubkey = $1;", [
pubkey,
]);
} else {
await this.#db.execute(
'INSERT OR IGNORE INTO accounts (id, pubkey, is_active) VALUES ($1, $2, $3);',
[id, pubkey, 1]
);
if (privkey) await this.#keyring_save(pubkey, privkey);
}
const account = await this.getActiveAccount();
this.account = account;
this.account.contacts = [];
return account;
}
/**
* Save private key to OS secure storage
* @deprecated this method will be remove in the next update
*/
public async createPrivkey(name: string, privkey: string) {
return await this.#keyring_save(name, privkey);
}
/**
* Load private key from OS secure storage
* @deprecated this method will be remove in the next update
*/
public async loadPrivkey(name: string) {
return await this.#keyring_load(name);
}
/**
* Remove private key from OS secure storage
* @deprecated this method will be remove in the next update
*/
public async removePrivkey(name: string) {
return await this.#keyring_remove(name);
}
public async updateAccount(column: string, value: string) {
const insert = await this.#db.execute(
`UPDATE accounts SET ${column} = $1 WHERE id = $2;`,
[value, this.account.id]
);
if (insert) {
const account = await this.getActiveAccount();
return account;
}
}
public async getWidgets() {
const widgets: Array<WidgetProps> = await this.#db.select(
'SELECT * FROM widgets WHERE account_id = $1 ORDER BY created_at DESC;',
[this.account.id]
);
return widgets;
}
public async createWidget(kind: number, title: string, content: string | string[]) {
const insert = await this.#db.execute(
'INSERT INTO widgets (account_id, kind, title, content) VALUES ($1, $2, $3, $4);',
[this.account.id, kind, title, content]
);
if (insert) {
const widgets: Array<WidgetProps> = await this.#db.select(
'SELECT * FROM widgets ORDER BY id DESC LIMIT 1;'
);
if (widgets.length < 1) console.error('get created widget failed');
return widgets[0];
} else {
console.error('create widget failed');
}
}
public async removeWidget(id: string) {
const res = await this.#db.execute('DELETE FROM widgets WHERE id = $1;', [id]);
if (res) return id;
}
public async createSetting(key: string, value: string | undefined) {
const currentSetting = await this.checkSettingValue(key);
if (!currentSetting) {
return await this.#db.execute(
'INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2);',
[key, value]
);
}
return await this.#db.execute('UPDATE settings SET value = $1 WHERE key = $2;', [
value,
key,
]);
}
public async getAllSettings() {
const results: { key: string; value: string }[] = await this.#db.select(
'SELECT * FROM settings ORDER BY id DESC;'
);
if (results.length < 1) return [];
return results;
}
public async checkSettingValue(key: string) {
const results: { key: string; value: string }[] = await this.#db.select(
'SELECT * FROM settings WHERE key = $1 ORDER BY id DESC LIMIT 1;',
[key]
);
if (!results.length) return false;
return results[0].value;
}
public async getSettingValue(key: string) {
const results: { key: string; value: string }[] = await this.#db.select(
'SELECT * FROM settings WHERE key = $1 ORDER BY id DESC LIMIT 1;',
[key]
);
if (!results.length) return '0';
return results[0].value;
}
public async clearCache() {
await this.#db.execute('DELETE FROM ndk_events;');
await this.#db.execute('DELETE FROM ndk_eventtags;');
await this.#db.execute('DELETE FROM ndk_users;');
}
public async logout() {
this.account = null;
return await this.#db.execute("UPDATE accounts SET is_active = '0' WHERE id = $1;", [
this.account.id,
]);
}
}

View File

@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { ArkProvider } from '@libs/ark/provider'; import { LumeProvider } from '@libs/ark';
import App from './app'; import App from './app';
import './app.css'; import './app.css';
@@ -19,8 +19,8 @@ const root = createRoot(container);
root.render( root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Toaster position="top-center" theme="system" closeButton /> <Toaster position="top-center" theme="system" closeButton />
<ArkProvider> <LumeProvider>
<App /> <App />
</ArkProvider> </LumeProvider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -2,20 +2,20 @@ import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons'; import { minidenticon } from 'minidenticons';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { useArk } from '@libs/ark'; import { useStorage } from '@libs/ark';
import { AccountMoreActions } from '@shared/accounts/more'; import { AccountMoreActions } from '@shared/account/more';
import { useNetworkStatus } from '@utils/hooks/useNetworkStatus'; import { useNetworkStatus } from '@utils/hooks/useNetworkStatus';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
export function ActiveAccount() { export function ActiveAccount() {
const ark = useArk(); const storage = useStorage();
const { user } = useProfile(ark.account.pubkey);
const isOnline = useNetworkStatus(); const isOnline = useNetworkStatus();
const { user } = useProfile(storage.account.pubkey);
const svgURI = const svgURI =
'data:image/svg+xml;utf8,' + 'data:image/svg+xml;utf8,' +
encodeURIComponent(minidenticon(ark.account.pubkey, 90, 50)); encodeURIComponent(minidenticon(storage.account.pubkey, 90, 50));
return ( return (
<div className="flex flex-col gap-1 rounded-xl bg-black/10 p-1 ring-1 ring-transparent hover:bg-black/20 hover:ring-blue-500 dark:bg-white/10 dark:hover:bg-white/20"> <div className="flex flex-col gap-1 rounded-xl bg-black/10 p-1 ring-1 ring-transparent hover:bg-black/20 hover:ring-blue-500 dark:bg-white/10 dark:hover:bg-white/20">
@@ -23,7 +23,7 @@ export function ActiveAccount() {
<Avatar.Root> <Avatar.Root>
<Avatar.Image <Avatar.Image
src={user?.picture || user?.image} src={user?.picture || user?.image}
alt={ark.account.pubkey} alt={storage.account.pubkey}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
style={{ contentVisibility: 'auto' }} style={{ contentVisibility: 'auto' }}
@@ -32,7 +32,7 @@ export function ActiveAccount() {
<Avatar.Fallback delayMs={150}> <Avatar.Fallback delayMs={150}>
<img <img
src={svgURI} src={svgURI}
alt={ark.account.pubkey} alt={storage.account.pubkey}
className="aspect-square h-auto w-full rounded-lg bg-black dark:bg-white" className="aspect-square h-auto w-full rounded-lg bg-black dark:bg-white"
/> />
</Avatar.Fallback> </Avatar.Fallback>

View File

@@ -1,6 +1,6 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Logout } from '@shared/accounts/logout'; import { Logout } from '@shared/account/logout';
import { HorizontalDotsIcon } from '@shared/icons'; import { HorizontalDotsIcon } from '@shared/icons';
export function AccountMoreActions() { export function AccountMoreActions() {

View File

@@ -1,6 +1,6 @@
import { Link, NavLink } from 'react-router-dom'; import { Link, NavLink } from 'react-router-dom';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { ActiveAccount } from '@shared/accounts/active'; import { ActiveAccount } from '@shared/account/active';
import { import {
DepotIcon, DepotIcon,
HomeIcon, HomeIcon,

View File

@@ -1,87 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import * as Tooltip from '@radix-ui/react-tooltip';
import { createSearchParams, useNavigate } from 'react-router-dom';
import { PinIcon, ReplyIcon } from '@shared/icons';
import { NoteReaction } from '@shared/notes/actions/reaction';
import { NoteRepost } from '@shared/notes/actions/repost';
import { NoteZap } from '@shared/notes/actions/zap';
import { WIDGET_KIND } from '@utils/constants';
import { useWidget } from '@utils/hooks/useWidget';
export function NoteActions({
event,
rootEventId,
canOpenEvent = true,
}: {
event: NDKEvent;
rootEventId?: string;
canOpenEvent?: boolean;
}) {
const { addWidget } = useWidget();
const navigate = useNavigate();
return (
<Tooltip.Provider>
<div className="flex h-14 items-center justify-between px-3">
{canOpenEvent && (
<div className="inline-flex items-center gap-3">
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: event.id,
})
}
className="inline-flex h-7 w-max items-center justify-center gap-2 rounded-full bg-neutral-100 px-2 text-sm font-medium dark:bg-neutral-900"
>
<PinIcon className="size-4" />
Pin
</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">
Pin note
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</div>
)}
<div className="inline-flex items-center gap-10">
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() =>
navigate({
pathname: '/new/',
search: createSearchParams({
replyTo: event.id,
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>
<NoteReaction event={event} />
<NoteRepost event={event} />
<NoteZap event={event} />
</div>
</div>
</Tooltip.Provider>
);
}

View File

@@ -1,63 +0,0 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/types/nip19';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { HorizontalDotsIcon } from '@shared/icons';
export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
const [open, setOpen] = useState(false);
const copyID = async () => {
await writeText(nip19.neventEncode({ id: id, author: pubkey } as EventPointer));
setOpen(false);
};
const copyLink = async () => {
await writeText(
'https://njump.me/' + nip19.neventEncode({ id: id, author: pubkey } as EventPointer)
);
setOpen(false);
};
return (
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
<DropdownMenu.Trigger asChild>
<button type="button" className="inline-flex h-6 w-6 items-center justify-center">
<HorizontalDotsIcon className="h-4 w-4 text-neutral-800 hover:text-blue-500 dark:text-neutral-200" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyLink()}
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
>
Copy shareable link
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyID()}
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
>
Copy ID
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<Link
to={`/users/${pubkey}`}
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
>
View profile
</Link>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@@ -1,128 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import * as Popover from '@radix-ui/react-popover';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useArk } from '@libs/ark';
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 ark = useArk();
const navigate = useNavigate();
const getReactionImage = (content: string) => {
const reaction: { img: string } = REACTIONS.find((el) => el.content === content);
return reaction.img;
};
const react = async (content: string) => {
try {
if (!ark.readyToSign) return navigate('/new/privkey');
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

@@ -1,100 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import * as Tooltip from '@radix-ui/react-tooltip';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge';
import { useArk } from '@libs/ark';
import { LoaderIcon, RepostIcon } from '@shared/icons';
export function NoteRepost({ event }: { event: NDKEvent }) {
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const ark = useArk();
const navigate = useNavigate();
const submit = async () => {
try {
if (!ark.readyToSign) return navigate('/new/privkey');
setIsLoading(true);
// repsot
await event.repost(true);
// reset state
setOpen(false);
setIsRepost(true);
toast.success("You've reposted this post successfully");
} catch (e) {
setIsLoading(false);
toast.error('Repost failed, try again later');
}
};
return (
<AlertDialog.Root open={open} onOpenChange={setOpen}>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<AlertDialog.Trigger asChild>
<button
type="button"
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>
</AlertDialog.Trigger>
</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>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
<AlertDialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-md rounded-xl bg-white dark:bg-black">
<div className="flex flex-col gap-2 border-b border-neutral-100 px-5 py-6 dark:border-neutral-900">
<AlertDialog.Title className="text-lg font-semibold leading-none text-neutral-900 dark:text-neutral-100">
Confirm repost this post?
</AlertDialog.Title>
<AlertDialog.Description className="text-sm leading-tight text-neutral-600 dark:text-neutral-400">
Reposted post will be visible to your followers, and you cannot undo this
action.
</AlertDialog.Description>
</div>
<div className="flex justify-end gap-2 px-3 py-3">
<AlertDialog.Cancel asChild>
<button className="inline-flex h-9 w-20 items-center justify-center rounded-md text-sm font-medium text-neutral-600 outline-none hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400">
Cancel
</button>
</AlertDialog.Cancel>
<button
type="button"
onClick={() => submit()}
className="inline-flex h-9 w-24 items-center justify-center rounded-md bg-blue-500 text-sm font-medium leading-none text-white outline-none hover:bg-blue-600"
>
{isLoading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
) : (
'Yes, repost'
)}
</button>
</div>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
}

View File

@@ -1,252 +0,0 @@
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 ark = useArk();
const { user } = useProfile(event.pubkey);
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 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,73 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { memo } from 'react';
import { Link } from 'react-router-dom';
import { User } from '@shared/user';
import { NoteActions } from './actions';
export function ArticleNote({ event }: { event: NDKEvent }) {
const getMetadata = () => {
const title = event.tags.find((tag) => tag[0] === 'title')?.[1];
const image = event.tags.find((tag) => tag[0] === 'image')?.[1];
const summary = event.tags.find((tag) => tag[0] === 'summary')?.[1];
let publishedAt: Date | string | number = event.tags.find(
(tag) => tag[0] === 'published_at'
)?.[1];
if (publishedAt) {
publishedAt = new Date(parseInt(publishedAt) * 1000).toLocaleDateString('en-US');
} else {
publishedAt = new Date(event.created_at * 1000).toLocaleDateString('en-US');
}
return {
title,
image,
publishedAt,
summary,
};
};
const metadata = getMetadata();
return (
<div className="mb-3 h-min w-full px-3">
<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} />
<div className="px-3">
<Link
to={`/notes/article/${event.id}`}
preventScrollReset={true}
className="flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900"
>
{metadata.image && (
<img
src={metadata.image}
alt={metadata.title}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-auto w-full rounded-t-lg object-cover"
/>
)}
<div className="flex flex-col gap-1 rounded-b-lg rounded-t-lg bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<h5 className="break-all font-semibold text-neutral-900 dark:text-neutral-100">
{metadata.title}
</h5>
{metadata.summary ? (
<p className="line-clamp-3 break-all text-sm text-neutral-600 dark:text-neutral-400">
{metadata.summary}
</p>
) : null}
<span className="mt-2.5 text-sm text-neutral-600 dark:text-neutral-400">
{metadata.publishedAt.toString()}
</span>
</div>
</Link>
</div>
<NoteActions event={event} />
</div>
</div>
);
}
export const MemoizedArticleNote = memo(ArticleNote);

View File

@@ -1,38 +0,0 @@
import { NoteSkeleton } from '@shared/notes';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
export function ChildNote({ id, isRoot }: { id: string; isRoot?: boolean }) {
const { isFetching, isError, data } = useEvent(id);
if (isFetching) {
return <NoteSkeleton />;
}
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>
<User
pubkey={data?.pubkey}
time={data?.created_at}
variant="childnote"
subtext={isRoot ? 'posted' : 'replied'}
/>
</div>
);
}

View File

@@ -1,85 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { downloadDir } from '@tauri-apps/api/path';
import { download } from '@tauri-apps/plugin-upload';
import { MediaPlayer, MediaProvider } from '@vidstack/react';
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from '@vidstack/react/player/layouts/default';
import { memo } from 'react';
import { Link } from 'react-router-dom';
import { DownloadIcon } from '@shared/icons';
import { NoteActions } from '@shared/notes';
import { User } from '@shared/user';
import { fileType } from '@utils/nip94';
export function FileNote({ event }: { event: NDKEvent }) {
const downloadImage = async (url: string) => {
const downloadDirPath = await downloadDir();
const filename = url.substring(url.lastIndexOf('/') + 1);
return await download(url, downloadDirPath + `/${filename}`);
};
const renderFileType = () => {
const url = event.tags.find((el) => el[0] === 'url')[1];
const type = fileType(url);
switch (type) {
case 'image':
return (
<div className="group relative">
<img
src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-auto w-full object-cover"
/>
<button
type="button"
onClick={() => downloadImage(url)}
className="absolute right-2 top-2 hidden h-10 w-10 items-center justify-center rounded-lg bg-black/50 backdrop-blur-xl group-hover:inline-flex hover:bg-blue-500"
>
<DownloadIcon className="h-5 w-5 text-white" />
</button>
</div>
);
case 'video':
return (
<MediaPlayer
src={url}
className="w-full overflow-hidden rounded-lg"
aspectRatio="16/9"
load="visible"
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
);
default:
return (
<Link
to={url}
target="_blank"
rel="noreferrer"
className="text-blue-500 hover:text-blue-600"
>
{url}
</Link>
);
}
};
return (
<div className="mb-3 h-min w-full px-3">
<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} />
<div className="relative mt-2">{renderFileType()}</div>
<NoteActions event={event} />
</div>
</div>
);
}
export const MemoizedFileNote = memo(FileNote);

View File

@@ -1,28 +0,0 @@
export * from './text';
export * from './repost';
export * from './file';
export * from './article';
export * from './child';
export * from './notify';
export * from './unknown';
export * from './skeleton';
export * from './actions';
export * from './actions/reaction';
export * from './actions/repost';
export * from './actions/zap';
export * from './actions/more';
export * from './preview/image';
export * from './preview/link';
export * from './preview/video';
export * from './replies/form';
export * from './replies/item';
export * from './replies/list';
export * from './replies/sub';
export * from './replies/replyMediaUploader';
export * from './mentions/note';
export * from './mentions/user';
export * from './mentions/hashtag';
export * from './mentions/invoice';
export * from './kinds/text';
export * from './kinds/article';
export * from './kinds/file';

View File

@@ -1,24 +0,0 @@
import { memo } from 'react';
import { useRichContent } from '@utils/hooks/useRichContent';
export function TextKind({ content, textmode }: { content: string; textmode?: boolean }) {
const { parsedContent } = useRichContent(content, textmode);
if (textmode) {
return (
<div className="line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{parsedContent}
</div>
);
}
return (
<div className="min-w-0 px-3">
<div className="break-p select-text leading-normal text-neutral-900 dark:text-neutral-100">
{parsedContent}
</div>
</div>
);
}
export const MemoizedTextKind = memo(TextKind);

View File

@@ -1,78 +0,0 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { memo } from 'react';
import {
MemoizedArticleKind,
MemoizedFileKind,
MemoizedTextKind,
NoteSkeleton,
} from '@shared/notes';
import { User } from '@shared/user';
import { WIDGET_KIND } from '@utils/constants';
import { useEvent } from '@utils/hooks/useEvent';
import { useWidget } from '@utils/hooks/useWidget';
export const MentionNote = memo(function MentionNote({
id,
editing,
}: {
id: string;
editing?: boolean;
}) {
const { isFetching, isError, data } = useEvent(id);
const { addWidget } = useWidget();
const renderKind = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <MemoizedTextKind content={event.content} textmode />;
case NDKKind.Article:
return <MemoizedArticleKind id={event.id} tags={event.tags} />;
case 1063:
return <MemoizedFileKind tags={event.tags} />;
default:
return null;
}
};
if (isFetching) {
return (
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
);
}
if (isError) {
return (
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
Failed to fetch event
</div>
);
}
return (
<div className="my-2 flex w-full cursor-default flex-col gap-1 rounded-lg bg-neutral-100 dark:bg-neutral-900">
<div className="mt-3 px-3">
<User pubkey={data.pubkey} time={data.created_at} variant="mention" />
</div>
<div className="mt-1 px-3 pb-3">
{renderKind(data)}
{!editing ? (
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: data.id,
})
}
className="mt-2 text-blue-500 hover:text-blue-600"
>
Show more
</button>
) : null}
</div>
</div>
);
});

View File

@@ -1,155 +0,0 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { memo } from 'react';
import { useArk } from '@libs/ark';
import { ReplyIcon, RepostIcon } from '@shared/icons';
import { ChildNote, TextKind } from '@shared/notes';
import { User } from '@shared/user';
import { WIDGET_KIND } from '@utils/constants';
import { formatCreatedAt } from '@utils/formater';
import { useWidget } from '@utils/hooks/useWidget';
export function NotifyNote({ event }: { event: NDKEvent }) {
const ark = useArk();
const { addWidget } = useWidget();
const thread = ark.getEventThread({ tags: event.tags });
const createdAt = formatCreatedAt(event.created_at, false);
if (event.kind === NDKKind.Reaction) {
return (
<div className="mb-3 h-min w-full px-3">
<div className="flex flex-col gap-2 rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<div className="flex h-10 items-center justify-between">
<div className="relative flex w-full items-center gap-2 px-3 pt-2">
<div className="absolute -left-0.5 -top-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-blue-100 text-xs ring-2 ring-neutral-50 dark:bg-blue-900 dark:ring-neutral-950">
{event.content === '+' ? '👍' : event.content}
</div>
<div className="flex flex-1 items-center justify-between">
<div className="inline-flex items-center gap-1.5">
<User pubkey={event.pubkey} variant="notify" />
<p className="text-neutral-700 dark:text-neutral-300">reacted</p>
</div>
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<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 ? <ChildNote id={thread.rootEventId} /> : null}
</div>
</div>
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: thread.rootEventId,
})
}
className="self-start text-blue-500 hover:text-blue-600"
>
Show original post
</button>
</div>
</div>
</div>
);
}
if (event.kind === NDKKind.Repost) {
return (
<div className="mb-3 h-min w-full px-3">
<div className="flex flex-col gap-2 rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<div className="flex h-10 items-center justify-between">
<div className="relative flex w-full items-center gap-2 px-3 pt-2">
<div className="absolute -left-0.5 -top-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-teal-500 text-xs ring-2 ring-neutral-50 dark:ring-neutral-950">
<RepostIcon className="h-4 w-4 text-white" />
</div>
<div className="flex flex-1 items-center justify-between">
<div className="inline-flex items-center gap-1.5">
<User pubkey={event.pubkey} variant="notify" />
<p className="text-neutral-700 dark:text-neutral-300">reposted</p>
</div>
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<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 ? <ChildNote id={thread.rootEventId} /> : null}
</div>
</div>
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: thread.rootEventId,
})
}
className="self-start text-blue-500 hover:text-blue-600"
>
Show original post
</button>
</div>
</div>
</div>
);
}
if (event.kind === NDKKind.Text) {
return (
<div className="mb-3 h-min w-full px-3">
<div className="flex flex-col gap-2 rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<div className="flex h-10 items-center justify-between">
<div className="relative flex w-full items-center gap-2 px-3 pt-2">
<div className="absolute -left-0.5 -top-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-blue-500 text-xs ring-2 ring-neutral-50 dark:ring-neutral-950">
<ReplyIcon className="h-4 w-4 text-white" />
</div>
<div className="flex flex-1 items-center justify-between">
<div className="inline-flex items-center gap-1.5">
<User pubkey={event.pubkey} variant="notify" />
<p className="text-neutral-700 dark:text-neutral-300">replied</p>
</div>
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<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?.replyEventId ? (
<ChildNote id={thread?.replyEventId} />
) : thread?.rootEventId ? (
<ChildNote id={thread?.rootEventId} isRoot />
) : null}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: thread.replyEventId
? thread.replyEventId
: thread.rootEventId,
})
}
className="self-start text-blue-500 hover:text-blue-600"
>
Show full thread
</button>
</div>
</div>
<TextKind content={event.content} textmode />
</div>
</div>
</div>
);
}
}
export const MemoizedNotifyNote = memo(NotifyNote);

View File

@@ -1,58 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { ReplyMediaUploader } from '@shared/notes';
export function NoteReplyForm({ rootEvent }: { rootEvent: NDKEvent }) {
const ark = useArk();
const navigate = useNavigate();
const [value, setValue] = useState('');
const [loading, setLoading] = useState(false);
const submit = async () => {
try {
if (!ark.readyToSign) return navigate('/new/privkey');
setLoading(true);
// publish event
const publish = await ark.replyTo({ content: value, event: rootEvent });
if (publish) {
toast.success(`Broadcasted to ${publish.size} relays successfully.`);
// reset state
setValue('');
setLoading(false);
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="mt-3 flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Reply to this post..."
className="h-28 w-full resize-none rounded-t-xl border-transparent bg-neutral-100 px-5 py-4 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
spellCheck={false}
/>
<div className="inline-flex items-center justify-end gap-2 rounded-b-xl p-2">
<ReplyMediaUploader setValue={setValue} />
<button
onClick={() => submit()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-9 w-20 items-center justify-center rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Reply'}
</button>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More