From 95124e5ded56990f58eccf098ec4ef36ba09d88d Mon Sep 17 00:00:00 2001 From: reya Date: Thu, 7 Dec 2023 11:50:25 +0700 Subject: [PATCH] wip: ark --- src/app/auth/create.tsx | 42 +- src/app/auth/follow.tsx | 22 +- src/app/auth/import.tsx | 33 +- src/app/auth/tutorials/note.tsx | 24 +- src/app/chats/chat.tsx | 25 +- src/app/chats/index.tsx | 13 +- src/app/new/article.tsx | 24 +- src/app/new/file.tsx | 22 +- src/app/new/post.tsx | 13 +- src/app/new/privkey.tsx | 18 +- src/app/settings/components/contactCard.tsx | 11 +- src/app/settings/components/relayCard.tsx | 13 +- src/app/settings/editContact.tsx | 15 +- src/app/settings/editProfile.tsx | 49 +- src/libs/ark/ark.ts | 661 ++++++++++++++++++ src/libs/ark/cache.ts | 539 ++++++++++++++ src/libs/ark/index.ts | 3 + src/libs/ark/provider.tsx | 123 ++++ src/main.jsx | 11 +- src/shared/accounts/logout.tsx | 14 +- src/shared/notes/repost.tsx | 15 +- src/shared/widgets/other/liveUpdater.tsx | 32 +- .../widgets/other/nostrBandUserProfile.tsx | 16 +- src/utils/hooks/useEvent.ts | 32 +- src/utils/hooks/useProfile.ts | 11 +- src/utils/hooks/useRelay.ts | 53 +- src/utils/hooks/useRichContent.tsx | 8 +- src/utils/types.d.ts | 6 + 28 files changed, 1547 insertions(+), 301 deletions(-) create mode 100644 src/libs/ark/ark.ts create mode 100644 src/libs/ark/cache.ts create mode 100644 src/libs/ark/index.ts create mode 100644 src/libs/ark/provider.tsx diff --git a/src/app/auth/create.tsx b/src/app/auth/create.tsx index aa8f5d40..a1ebd64c 100644 --- a/src/app/auth/create.tsx +++ b/src/app/auth/create.tsx @@ -1,4 +1,4 @@ -import { NDKEvent, NDKKind, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; +import { NDKKind, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; import { downloadDir } from '@tauri-apps/api/path'; import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { save } from '@tauri-apps/plugin-dialog'; @@ -11,8 +11,7 @@ import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; -import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; import { AvatarUploader } from '@shared/avatarUploader'; import { ArrowLeftIcon, InfoIcon, LoaderIcon } from '@shared/icons'; @@ -29,13 +28,12 @@ export function CreateAccountScreen() { privkey: string; }>(null); + const { ark } = useArk(); const { register, handleSubmit, formState: { isDirty, isValid }, } = useForm(); - const { db } = useStorage(); - const { ndk } = useNDK(); const navigate = useNavigate(); @@ -62,28 +60,22 @@ export function CreateAccountScreen() { const userNsec = nip19.nsecEncode(userPrivkey); const signer = new NDKPrivateKeySigner(userPrivkey); - ndk.signer = signer; + ark.updateNostrSigner({ signer }); - const event = new NDKEvent(ndk); - event.content = JSON.stringify(profile); - event.kind = NDKKind.Metadata; - event.pubkey = userPubkey; - event.tags = []; - - const publish = await event.publish(); + const publish = await ark.createEvent({ + content: JSON.stringify(profile), + kind: NDKKind.Metadata, + tags: [], + publish: true, + }); if (publish) { - await db.createAccount(userNpub, userPubkey); - await db.secureSave(userPubkey, userPrivkey); - - const relayListEvent = new NDKEvent(ndk); - relayListEvent.kind = NDKKind.RelayList; - relayListEvent.tags = [...ndk.pool.relays.values()].map((item) => [ - 'r', - item.url, - ]); - - await relayListEvent.publish(); + await ark.createAccount(userNpub, userPubkey, userPrivkey); + await ark.createEvent({ + kind: NDKKind.RelayList, + tags: [ark.relays], + publish: true, + }); setKeys({ npub: userNpub, @@ -93,7 +85,7 @@ export function CreateAccountScreen() { }); setLoading(false); } else { - toast('Create account failed'); + toast('Cannot publish user profile, please try again later.'); setLoading(false); } } catch (e) { diff --git a/src/app/auth/follow.tsx b/src/app/auth/follow.tsx index cf436143..6805f6c7 100644 --- a/src/app/auth/follow.tsx +++ b/src/app/auth/follow.tsx @@ -1,4 +1,4 @@ -import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; +import { NDKKind } from '@nostr-dev-kit/ndk'; import * as Accordion from '@radix-ui/react-accordion'; import { useQuery } from '@tanstack/react-query'; import { nip19 } from 'nostr-tools'; @@ -7,8 +7,7 @@ import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { twMerge } from 'tailwind-merge'; -import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; import { ArrowLeftIcon, @@ -37,8 +36,7 @@ const POPULAR_USERS = [ const LUME_USERS = ['npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445']; export function FollowScreen() { - const { ndk } = useNDK(); - const { db } = useStorage(); + const { ark } = useArk(); const { status, data } = useQuery({ queryKey: ['trending-profiles-widget'], queryFn: async () => { @@ -68,16 +66,16 @@ export function FollowScreen() { setLoading(true); if (!follows.length) return navigate('/auth/finish'); - const event = new NDKEvent(ndk); - event.kind = NDKKind.Contacts; - event.tags = follows.map((item) => { - if (item.startsWith('npub')) return ['p', nip19.decode(item).data as string]; - return ['p', item]; + const publish = await ark.createEvent({ + kind: NDKKind.Contacts, + tags: follows.map((item) => { + if (item.startsWith('npub')) return ['p', nip19.decode(item).data as string]; + return ['p', item]; + }), }); - const publish = await event.publish(); if (publish) { - db.account.contacts = follows.map((item) => { + ark.account.contacts = follows.map((item) => { if (item.startsWith('npub')) return nip19.decode(item).data as string; return item; }); diff --git a/src/app/auth/import.tsx b/src/app/auth/import.tsx index 7a995d3a..78744aac 100644 --- a/src/app/auth/import.tsx +++ b/src/app/auth/import.tsx @@ -8,16 +8,12 @@ import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { twMerge } from 'tailwind-merge'; -import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; import { ArrowLeftIcon, LoaderIcon } from '@shared/icons'; import { User } from '@shared/user'; export function ImportAccountScreen() { - const { db } = useStorage(); - const { ndk } = useNDK(); - const [npub, setNpub] = useState(''); const [nsec, setNsec] = useState(''); const [pubkey, setPubkey] = useState(undefined); @@ -25,6 +21,7 @@ export function ImportAccountScreen() { const [created, setCreated] = useState({ ok: false, remote: false }); const [savedPrivkey, setSavedPrivkey] = useState(false); + const { ark } = useArk(); const navigate = useNavigate(); const submitNpub = async () => { @@ -47,8 +44,8 @@ export function ImportAccountScreen() { const pubkey = nip19.decode(npub.split('#')[0]).data as string; const localSigner = NDKPrivateKeySigner.generate(); - await db.createSetting('nsecbunker', '1'); - await db.secureSave(`${pubkey}-nsecbunker`, localSigner.privateKey); + await ark.createSetting('nsecbunker', '1'); + await ark.createPrivkey(`${pubkey}-nsecbunker`, localSigner.privateKey); // open nsecbunker web app in default browser await open('https://app.nsecbunker.com/keys'); @@ -60,8 +57,7 @@ export function ImportAccountScreen() { const remoteSigner = new NDKNip46Signer(bunker, npub, localSigner); await remoteSigner.blockUntilReady(); - - ndk.signer = remoteSigner; + ark.updateNostrSigner({ signer: remoteSigner }); setPubkey(pubkey); setCreated({ ok: false, remote: true }); @@ -80,14 +76,10 @@ export function ImportAccountScreen() { setLoading(true); // add account to db - await db.createAccount(npub, pubkey); + await ark.createAccount(npub, pubkey); - // get account metadata - const user = ndk.getUser({ pubkey }); - if (user) { - db.account.contacts = [...(await user.follows())].map((user) => user.pubkey); - db.account.relayList = await user.relayList(); - } + // get account contacts + await ark.getUserContacts({ pubkey }); setCreated((prev) => ({ ...prev, ok: true })); setLoading(false); @@ -109,9 +101,8 @@ export function ImportAccountScreen() { if (nsec.length > 50 && nsec.startsWith('nsec1')) { try { const privkey = nip19.decode(nsec).data as string; - await db.secureSave(pubkey, privkey); - - ndk.signer = new NDKPrivateKeySigner(privkey); + await ark.createPrivkey(pubkey, privkey); + ark.updateNostrSigner({ signer: new NDKPrivateKeySigner(privkey) }); setSavedPrivkey(true); } catch (e) { @@ -290,9 +281,9 @@ export function ImportAccountScreen() {

Lume will put your private key to{' '} - {db.platform === 'macos' + {ark.platform === 'macos' ? 'Apple Keychain (macOS)' - : db.platform === 'windows' + : ark.platform === 'windows' ? 'Credential Manager (Windows)' : 'Secret Service (Linux)'} diff --git a/src/app/auth/tutorials/note.tsx b/src/app/auth/tutorials/note.tsx index 5a6a7170..b8a21434 100644 --- a/src/app/auth/tutorials/note.tsx +++ b/src/app/auth/tutorials/note.tsx @@ -1,21 +1,23 @@ -import { NDKEvent } from '@nostr-dev-kit/ndk'; import { Link } from 'react-router-dom'; -import { useNDK } from '@libs/ndk/provider'; +import { useArk } from '@libs/ark'; import { EditIcon, ReactionIcon, ReplyIcon, RepostIcon, ZapIcon } from '@shared/icons'; import { TextNote } from '@shared/notes'; export function TutorialNoteScreen() { - const { ndk } = useNDK(); - const exampleEvent = new NDKEvent(ndk, { - id: 'a3527670dd9b178bf7c2a9ea673b63bc8bfe774942b196691145343623c45821', - pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9', - created_at: 1701355223, - kind: 1, - tags: [], - content: 'good morning nostr, stay humble and stack sats 🫡', - sig: '9e0bd67ec25598744f20bff0fe360fdf190c4240edb9eea260e50f77e07f94ea767ececcc6270819b7f64e5e7ca1fe20b4971f46dc120e6db43114557f3a6dae', + const { ark } = useArk(); + + const exampleEvent = ark.createNDKEvent({ + event: { + id: 'a3527670dd9b178bf7c2a9ea673b63bc8bfe774942b196691145343623c45821', + pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9', + created_at: 1701355223, + kind: 1, + tags: [], + content: 'good morning nostr, stay humble and stack sats 🫡', + sig: '9e0bd67ec25598744f20bff0fe360fdf190c4240edb9eea260e50f77e07f94ea767ececcc6270819b7f64e5e7ca1fe20b4971f46dc120e6db43114557f3a6dae', + }, }); return ( diff --git a/src/app/chats/chat.tsx b/src/app/chats/chat.tsx index 4767cc6f..5f66f3d8 100644 --- a/src/app/chats/chat.tsx +++ b/src/app/chats/chat.tsx @@ -1,4 +1,4 @@ -import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'; +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'; @@ -7,8 +7,7 @@ import { VList, VListHandle } from 'virtua'; import { ChatForm } from '@app/chats/components/chatForm'; import { ChatMessage } from '@app/chats/components/message'; -import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; import { LoaderIcon } from '@shared/icons'; import { User } from '@shared/user'; @@ -16,8 +15,7 @@ import { User } from '@shared/user'; import { useNostr } from '@utils/hooks/useNostr'; export function ChatScreen() { - const { db } = useStorage(); - const { ndk } = useNDK(); + const { ark } = useArk(); const { pubkey } = useParams(); const { fetchNIP04Messages } = useNostr(); const { status, data } = useQuery({ @@ -59,7 +57,7 @@ export function ChatScreen() { ); }, @@ -71,20 +69,15 @@ export function ChatScreen() { }, [data]); useEffect(() => { - const sub: NDKSubscription = ndk.subscribe( - { + const sub = ark.subscribe({ + filter: { kinds: [4], - authors: [db.account.pubkey], + authors: [ark.account.pubkey], '#p': [pubkey], since: Math.floor(Date.now() / 1000), }, - { - closeOnEose: false, - } - ); - - sub.addListener('event', (event) => { - newMessage.mutate(event); + closeOnEose: false, + cb: (event) => newMessage.mutate(event), }); return () => { diff --git a/src/app/chats/index.tsx b/src/app/chats/index.tsx index 40364e94..67698c9a 100644 --- a/src/app/chats/index.tsx +++ b/src/app/chats/index.tsx @@ -1,20 +1,15 @@ import { NDKEvent } from '@nostr-dev-kit/ndk'; import { useQuery } from '@tanstack/react-query'; -import { useCallback, useEffect } from 'react'; -import { Outlet, useNavigate } from 'react-router-dom'; +import { useCallback } from 'react'; +import { Outlet } from 'react-router-dom'; import { ChatListItem } from '@app/chats/components/chatListItem'; -import { useNDK } from '@libs/ndk/provider'; - import { LoaderIcon } from '@shared/icons'; import { useNostr } from '@utils/hooks/useNostr'; export function ChatsScreen() { - const navigate = useNavigate(); - - const { ndk } = useNDK(); const { getAllNIP04Chats } = useNostr(); const { status, data } = useQuery({ queryKey: ['nip04-chats'], @@ -34,10 +29,6 @@ export function ChatsScreen() { [data] ); - useEffect(() => { - if (!ndk.signer) navigate('/new/privkey'); - }, []); - return (

diff --git a/src/app/new/article.tsx b/src/app/new/article.tsx index ad9e0bd4..47b2ef21 100644 --- a/src/app/new/article.tsx +++ b/src/app/new/article.tsx @@ -1,4 +1,4 @@ -import { NDKEvent, NDKKind, NDKTag } from '@nostr-dev-kit/ndk'; +import { NDKKind, NDKTag } from '@nostr-dev-kit/ndk'; import CharacterCount from '@tiptap/extension-character-count'; import Image from '@tiptap/extension-image'; import Placeholder from '@tiptap/extension-placeholder'; @@ -12,7 +12,7 @@ import { Markdown } from 'tiptap-markdown'; import { ArticleCoverUploader, MediaUploader, MentionPopup } from '@app/new/components'; -import { useNDK } from '@libs/ndk/provider'; +import { useArk } from '@libs/ark'; import { BoldIcon, @@ -25,7 +25,7 @@ import { } from '@shared/icons'; export function NewArticleScreen() { - const { ndk } = useNDK(); + const { ark } = useArk(); const [height, setHeight] = useState(0); const [loading, setLoading] = useState(false); @@ -69,7 +69,7 @@ export function NewArticleScreen() { const submit = async () => { try { - if (!ndk.signer) return navigate('/new/privkey'); + if (!ark.readyToSign) return navigate('/new/privkey'); setLoading(true); @@ -91,16 +91,16 @@ export function NewArticleScreen() { tags.push(['t', tag.replace('#', '')]); }); - const event = new NDKEvent(ndk); - event.content = content; - event.kind = NDKKind.Article; - event.tags = tags; - // publish - const publishedRelays = await event.publish(); + const publish = await ark.createEvent({ + content, + tags, + kind: NDKKind.Article, + publish: true, + }); - if (publishedRelays) { - toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`); + if (publish) { + toast.success(`Broadcasted to ${publish} relays successfully.`); // update state setLoading(false); diff --git a/src/app/new/file.tsx b/src/app/new/file.tsx index f67c618c..d4c8cddf 100644 --- a/src/app/new/file.tsx +++ b/src/app/new/file.tsx @@ -1,16 +1,15 @@ -import { NDKEvent } from '@nostr-dev-kit/ndk'; import { message, open } from '@tauri-apps/plugin-dialog'; import { readBinaryFile } from '@tauri-apps/plugin-fs'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; -import { useNDK } from '@libs/ndk/provider'; +import { useArk } from '@libs/ark'; import { LoaderIcon } from '@shared/icons'; export function NewFileScreen() { - const { ndk } = useNDK(); + const { ark } = useArk(); const navigate = useNavigate(); const [loading, setLoading] = useState(false); @@ -86,20 +85,21 @@ export function NewFileScreen() { const submit = async () => { try { - if (!ndk.signer) return navigate('/new/privkey'); + if (!ark.readyToSign) return navigate('/new/privkey'); setIsPublish(true); - const event = new NDKEvent(ndk); - event.content = caption; - event.kind = 1063; - event.tags = metadata; + const publish = await ark.createEvent({ + kind: 1063, + tags: metadata, + content: caption, + publish: true, + }); - const publishedRelays = await event.publish(); - if (publishedRelays) { + if (publish) { + toast.success(`Broadcasted to ${publish} relays successfully.`); setMetadata(null); setIsPublish(false); - toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`); } } catch (e) { setIsPublish(false); diff --git a/src/app/new/post.tsx b/src/app/new/post.tsx index 68f36526..6a6def9a 100644 --- a/src/app/new/post.tsx +++ b/src/app/new/post.tsx @@ -13,7 +13,7 @@ import { toast } from 'sonner'; import { MediaUploader, MentionPopup } from '@app/new/components'; -import { useNDK } from '@libs/ndk/provider'; +import { useArk } from '@libs/ark'; import { CancelIcon, LoaderIcon } from '@shared/icons'; import { MentionNote } from '@shared/notes'; @@ -23,7 +23,7 @@ import { useSuggestion } from '@utils/hooks/useSuggestion'; import { useWidget } from '@utils/hooks/useWidget'; export function NewPostScreen() { - const { ndk } = useNDK(); + const { ark } = useArk(); const { addWidget } = useWidget(); const { suggestion } = useSuggestion(); @@ -68,7 +68,7 @@ export function NewPostScreen() { const submit = async () => { try { - if (!ndk.signer) return navigate('/new/privkey'); + if (!ark.readyToSign) return navigate('/new/privkey'); setLoading(true); @@ -81,7 +81,7 @@ export function NewPostScreen() { ], }); - const event = new NDKEvent(ndk); + const event = new NDKEvent(); event.content = serializedContent; event.kind = NDKKind.Text; @@ -100,7 +100,10 @@ export function NewPostScreen() { } // publish event - const publishedRelays = await event.publish(); + const publishedRelays = await ark.createEvent({ + kind: NDKKind.Text, + content: serializedContent, + }); if (publishedRelays) { toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`); diff --git a/src/app/new/privkey.tsx b/src/app/new/privkey.tsx index 29ae67d6..771b9f6d 100644 --- a/src/app/new/privkey.tsx +++ b/src/app/new/privkey.tsx @@ -4,19 +4,13 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; -import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; export function NewPrivkeyScreen() { - const { db } = useStorage(); - const { ndk } = useNDK(); - - const [nsec, setNsec] = useState(''); + const { ark } = useArk(); const navigate = useNavigate(); - const save = async (content: string) => { - return await db.secureSave(db.account.pubkey, content); - }; + const [nsec, setNsec] = useState(''); const submit = async (isSave?: boolean) => { try { @@ -30,15 +24,15 @@ export function NewPrivkeyScreen() { const privkey = decoded.data; const pubkey = getPublicKey(privkey); - if (pubkey !== db.account.pubkey) + if (pubkey !== ark.account.pubkey) return toast.info( 'Your nsec is not match your current public key, please make sure you enter right nsec' ); const signer = new NDKPrivateKeySigner(privkey); - ndk.signer = signer; + ark.updateNostrSigner({ signer }); - if (isSave) await save(privkey); + if (isSave) await ark.createPrivkey(ark.account.pubkey, privkey); navigate(-1); } catch (e) { diff --git a/src/app/settings/components/contactCard.tsx b/src/app/settings/components/contactCard.tsx index 29d3da76..eeda0f86 100644 --- a/src/app/settings/components/contactCard.tsx +++ b/src/app/settings/components/contactCard.tsx @@ -1,22 +1,19 @@ import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; -import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; import { EditIcon, LoaderIcon } from '@shared/icons'; import { compactNumber } from '@utils/number'; export function ContactCard() { - const { db } = useStorage(); - const { ndk } = useNDK(); + const { ark } = useArk(); const { status, data } = useQuery({ queryKey: ['contacts'], queryFn: async () => { - const user = ndk.getUser({ pubkey: db.account.pubkey }); - const follows = await user.follows(); - return [...follows]; + const contacts = await ark.getUserContacts({}); + return contacts; }, refetchOnWindowFocus: false, }); diff --git a/src/app/settings/components/relayCard.tsx b/src/app/settings/components/relayCard.tsx index f5591c1c..b05d3cf7 100644 --- a/src/app/settings/components/relayCard.tsx +++ b/src/app/settings/components/relayCard.tsx @@ -1,23 +1,18 @@ import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; -import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; import { EditIcon, LoaderIcon } from '@shared/icons'; import { compactNumber } from '@utils/number'; export function RelayCard() { - const { db } = useStorage(); - const { ndk } = useNDK(); + const { ark } = useArk(); const { status, data } = useQuery({ - queryKey: ['relays'], + queryKey: ['relays', ark.account.pubkey], queryFn: async () => { - const user = ndk.getUser({ pubkey: db.account.pubkey }); - const relays = await user.relayList(); - - if (!relays) return Promise.reject(new Error("user's relay set not found")); + const relays = await ark.getUserRelays({}); return relays; }, refetchOnWindowFocus: false, diff --git a/src/app/settings/editContact.tsx b/src/app/settings/editContact.tsx index 30dcd118..cbc873e7 100644 --- a/src/app/settings/editContact.tsx +++ b/src/app/settings/editContact.tsx @@ -1,21 +1,16 @@ import { useQuery } from '@tanstack/react-query'; -import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; import { LoaderIcon } from '@shared/icons'; import { User } from '@shared/user'; export function EditContactScreen() { - const { db } = useStorage(); - const { ndk } = useNDK(); + const { ark } = useArk(); const { status, data } = useQuery({ queryKey: ['contacts'], queryFn: async () => { - const user = ndk.getUser({ pubkey: db.account.pubkey }); - - const follows = await user.follows(); - return [...follows]; + return await ark.getUserContacts({}); }, refetchOnWindowFocus: false, }); @@ -29,10 +24,10 @@ export function EditContactScreen() { ) : ( data.map((item) => (
- +
)) )} diff --git a/src/app/settings/editProfile.tsx b/src/app/settings/editProfile.tsx index 3ba8ff21..03bc3633 100644 --- a/src/app/settings/editProfile.tsx +++ b/src/app/settings/editProfile.tsx @@ -1,29 +1,21 @@ -import { NDKEvent, NDKKind, NDKUserProfile } from '@nostr-dev-kit/ndk'; +import { NDKKind, NDKUserProfile } from '@nostr-dev-kit/ndk'; import { useQueryClient } from '@tanstack/react-query'; import { message } from '@tauri-apps/plugin-dialog'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; -import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; import { CheckCircleIcon, LoaderIcon, PlusIcon, UnverifiedIcon } from '@shared/icons'; -import { useNostr } from '@utils/hooks/useNostr'; - export function EditProfileScreen() { - const queryClient = useQueryClient(); - const navigate = useNavigate(); - const [loading, setLoading] = useState(false); const [picture, setPicture] = useState(''); const [banner, setBanner] = useState(''); const [nip05, setNIP05] = useState({ verified: true, text: '' }); - const { db } = useStorage(); - const { ndk } = useNDK(); - const { upload } = useNostr(); + const { ark } = useArk(); const { register, handleSubmit, @@ -32,7 +24,7 @@ export function EditProfileScreen() { formState: { isValid, errors }, } = useForm({ defaultValues: async () => { - const res: NDKUserProfile = queryClient.getQueryData(['user', db.account.pubkey]); + const res: NDKUserProfile = queryClient.getQueryData(['user', ark.account.pubkey]); if (res.image) { setPicture(res.image); } @@ -46,13 +38,16 @@ export function EditProfileScreen() { }, }); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const uploadAvatar = async () => { try { - if (!ndk.signer) return navigate('/new/privkey'); + if (!ark.readyToSign) return navigate('/new/privkey'); setLoading(true); - const image = await upload(); + const image = await ark.upload({}); if (image) { setPicture(image); setLoading(false); @@ -67,7 +62,7 @@ export function EditProfileScreen() { try { setLoading(true); - const image = await upload(); + const image = await ark.upload({}); if (image) { setBanner(image); @@ -83,7 +78,7 @@ export function EditProfileScreen() { // start loading setLoading(true); - const content = { + let content = { ...data, username: data.name, display_name: data.name, @@ -91,15 +86,10 @@ export function EditProfileScreen() { image: data.picture, }; - const event = new NDKEvent(ndk); - event.kind = NDKKind.Metadata; - event.tags = []; - if (data.nip05) { - const user = ndk.getUser({ pubkey: db.account.pubkey }); - const verify = await user.validateNip05(data.nip05); + const verify = ark.validateNIP05({ pubkey: ark.account.pubkey, nip05: data.nip05 }); if (verify) { - event.content = JSON.stringify({ ...content, nip05: data.nip05 }); + content = { ...content, nip05: data.nip05 }; } else { setNIP05((prev) => ({ ...prev, verified: false })); setError('nip05', { @@ -107,16 +97,19 @@ export function EditProfileScreen() { message: "Can't verify your Lume ID / NIP-05, please check again", }); } - } else { - event.content = JSON.stringify(content); } - const publishedRelays = await event.publish(); + const publish = await ark.createEvent({ + kind: NDKKind.Metadata, + tags: [], + content: JSON.stringify(content), + publish: true, + }); - if (publishedRelays) { + if (publish) { // invalid cache queryClient.invalidateQueries({ - queryKey: ['user', db.account.pubkey], + queryKey: ['user', ark.account.pubkey], }); // reset form reset(); diff --git a/src/libs/ark/ark.ts b/src/libs/ark/ark.ts new file mode 100644 index 00000000..cad18714 --- /dev/null +++ b/src/libs/ark/ark.ts @@ -0,0 +1,661 @@ +import NDK, { + NDKEvent, + NDKFilter, + NDKKind, + NDKNip46Signer, + NDKPrivateKeySigner, + NDKSubscriptionCacheUsage, + NDKTag, + NDKUser, + NostrEvent, +} from '@nostr-dev-kit/ndk'; +import { ndkAdapter } from '@nostr-fetch/adapter-ndk'; +import { invoke } from '@tauri-apps/api/primitives'; +import { open } from '@tauri-apps/plugin-dialog'; +import { readBinaryFile } from '@tauri-apps/plugin-fs'; +import { Platform } from '@tauri-apps/plugin-os'; +import Database from '@tauri-apps/plugin-sql'; +import { NostrEventExt, NostrFetcher, normalizeRelayUrlSet } from 'nostr-fetch'; +import { toast } from 'sonner'; + +import { NDKCacheAdapterTauri } from '@libs/ark'; + +import { + Account, + NDKCacheUser, + NDKCacheUserProfile, + NDKEventWithReplies, + NIP05, + Widget, +} from '@utils/types'; + +export class Ark { + #ndk: NDK; + #fetcher: NostrFetcher; + #storage: Database; + public account: Account | null; + public relays: string[] | null; + public readyToSign: boolean; + readonly platform: Platform | null; + readonly settings: { + autoupdate: boolean; + outbox: boolean; + media: boolean; + hashtag: boolean; + }; + + constructor({ storage }: { storage: Database }) { + this.#storage = storage; + this.#init(); + } + + 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 }) { + if (!this.account) { + this.readyToSign = false; + return null; + } + + try { + // NIP-46 Signer + if (nsecbunker) { + const localSignerPrivkey = await this.#keyring_load( + `${this.account.pubkey}-nsecbunker` + ); + + if (!localSignerPrivkey) { + this.readyToSign = false; + return null; + } + + const localSigner = new NDKPrivateKeySigner(localSignerPrivkey); + const bunker = new NDK({ + explicitRelayUrls: ['wss://relay.nsecbunker.com', 'wss://nostr.vulpem.com'], + }); + bunker.connect(); + + const remoteSigner = new NDKNip46Signer(bunker, this.account.id, 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); + if (e === 'Token already redeemed') { + toast.info( + 'nsecbunker token already redeemed. You need to re-login with another token.' + ); + await this.logout(); + } + + this.readyToSign = false; + return null; + } + } + + async #init() { + const outboxSetting = await this.getSettingValue('outbox'); + const bunkerSetting = await this.getSettingValue('nsecbunker'); + + const bunker = !!parseInt(bunkerSetting); + const enableOutboxModel = !!parseInt(outboxSetting); + + const explicitRelayUrls = normalizeRelayUrlSet([ + 'wss://relay.damus.io', + 'wss://relay.nostr.band', + 'wss://nos.lol', + 'wss://nostr.mutinywallet.com', + ]); + + // #TODO: user should config outbox relays + const outboxRelayUrls = normalizeRelayUrlSet(['wss://purplepag.es']); + + // #TODO: user should config blacklist relays + const blacklistRelayUrls = normalizeRelayUrlSet(['wss://brb.io']); + + const cacheAdapter = new NDKCacheAdapterTauri(this.#storage); + const ndk = new NDK({ + cacheAdapter, + explicitRelayUrls, + outboxRelayUrls, + blacklistRelayUrls, + enableOutboxModel, + autoConnectUserRelays: true, + autoFetchUserMutelist: true, + // clientName: 'Lume', + // clientNip89: '', + }); + + // add signer if exist + const signer = await this.#initNostrSigner({ nsecbunker: bunker }); + if (signer) ndk.signer = signer; + + // connect + await ndk.connect(); + 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(undefined /* outbox */); + this.account.contacts = [...contacts].map((user) => user.pubkey); + } + + this.#ndk = ndk; + this.#fetcher = fetcher; + } + + public updateNostrSigner({ signer }: { signer: NDKNip46Signer | NDKPrivateKeySigner }) { + this.#ndk.signer = signer; + return this.#ndk.signer; + } + + public async getAllCacheUsers() { + const results: Array = 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 = await this.#storage.select( + 'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;' + ); + + if (results.length) { + this.account = results[0]; + this.account.contacts = []; + } else { + console.log('no active account, please create new account'); + return null; + } + } + + public async createAccount(npub: string, pubkey: string, privkey?: string) { + const existAccounts: Array = 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);', + [npub, pubkey, 1] + ); + + if (privkey) await this.#keyring_save(pubkey, privkey); + } + + return await this.getActiveAccount(); + } + + /** + * Save private key to OS secure storage + * @deprecated this method will be marked as private in the next update + */ + public async createPrivkey(name: string, privkey: string) { + await this.#keyring_save(name, privkey); + } + + 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 = 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 = 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) { + if (value) { + return await this.#storage.execute( + 'INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2);', + [key, value] + ); + } + + 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] + ); + + const currentValue = !!parseInt(currentSetting); + + return await this.#storage.execute('UPDATE settings SET value = $1 WHERE key = $2;', [ + +!currentValue, + 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 null; + 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.#storage.execute("UPDATE accounts SET is_active = '0' WHERE id = $1;", [ + this.account.id, + ]); + await this.#keyring_remove(this.account.pubkey); + await this.#keyring_remove(`${this.account.pubkey}-nsecbunker`); + + this.account = null; + this.#ndk.signer = null; + } + + public subscribe({ + filter, + closeOnEose = false, + cb, + }: { + filter: NDKFilter; + closeOnEose: boolean; + cb: (event: NDKEvent) => void; + }) { + const sub = this.#ndk.subscribe(filter, { closeOnEose }); + sub.addListener('event', (event: NDKEvent) => cb(event)); + return sub; + } + + public createNDKEvent({ event }: { event: NostrEvent | NostrEventExt }) { + return new NDKEvent(this.#ndk, event); + } + + public async createEvent({ + kind, + tags, + content, + publish, + }: { + kind: NDKKind | number; + tags: NDKTag[]; + content?: string; + publish?: boolean; + }) { + try { + const event = new NDKEvent(this.#ndk); + if (content) event.content = content; + event.kind = kind; + event.tags = tags; + + if (!publish) { + const publish = await event.publish(); + if (!publish) throw new Error('cannot publish error'); + return publish.size; + } + + return event; + } catch (e) { + throw new Error(e); + } + } + + public async getUserProfile({ pubkey }: { pubkey: string }) { + try { + const user = this.#ndk.getUser({ pubkey }); + const profile = await user.fetchProfile({ + cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST, + }); + + if (!profile) return null; + return profile; + } catch (e) { + console.error(e); + return null; + } + } + + public async getUserContacts({ + pubkey = undefined, + outbox = undefined, + }: { + pubkey?: string; + outbox?: boolean; + }) { + try { + const user = this.#ndk.getUser({ pubkey: pubkey ? pubkey : this.account.pubkey }); + const contacts = [...(await user.follows(undefined, outbox))].map( + (user) => user.pubkey + ); + + this.account.contacts = contacts; + return contacts; + } catch (e) { + console.error(e); + return []; + } + } + + public async getUserRelays({ pubkey }: { pubkey?: string }) { + try { + const user = this.#ndk.getUser({ pubkey: pubkey ? pubkey : this.account.pubkey }); + return await user.relayList(); + } catch (e) { + console.error(e); + return null; + } + } + + public async createContact({ pubkey }: { pubkey: string }) { + const user = this.#ndk.getUser({ pubkey: this.account.pubkey }); + const contacts = await user.follows(); + return await user.follow(new NDKUser({ pubkey: pubkey }), contacts); + } + + public async deleteContact({ pubkey }: { pubkey: string }) { + const user = this.#ndk.getUser({ pubkey: this.account.pubkey }); + const contacts = await user.follows(); + return await user.follow(new NDKUser({ pubkey: pubkey }), contacts); + } + + public async getAllEvents({ filter }: { filter: NDKFilter }) { + const events = await this.#ndk.fetchEvents(filter); + if (!events) return []; + return [...events]; + } + + public async getEventById({ id }: { id: string }) { + const event = await this.#ndk.fetchEvent(id, { + cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST, + }); + + if (!event) return null; + return event; + } + + public getEventThread({ tags }: { tags: NDKTag[] }) { + let rootEventId: string = null; + let replyEventId: string = null; + + const events = tags.filter((el) => el[0] === 'e'); + + if (!events.length) return null; + + if (events.length === 1) + return { + rootEventId: events[0][1], + replyEventId: null, + }; + + if (events.length > 1) { + rootEventId = events.find((el) => el[3] === 'root')?.[1]; + replyEventId = events.find((el) => el[3] === 'reply')?.[1]; + + if (!rootEventId && !replyEventId) { + rootEventId = events[0][1]; + replyEventId = events[1][1]; + } + } + + return { + rootEventId, + replyEventId, + }; + } + + public async getThreads({ id, data }: { id: string; data?: NDKEventWithReplies[] }) { + let events = data || null; + + if (!data) { + const relayUrls = [...this.#ndk.pool.relays.values()].map((item) => item.url); + const rawEvents = (await this.#fetcher.fetchAllEvents( + relayUrls, + { + kinds: [NDKKind.Text], + '#e': [id], + }, + { since: 0 }, + { sort: true } + )) as unknown as NostrEvent[]; + events = rawEvents.map( + (event) => new NDKEvent(this.#ndk, event) + ) as NDKEvent[] as NDKEventWithReplies[]; + } + + if (events.length > 0) { + const replies = new Set(); + events.forEach((event) => { + const tags = event.tags.filter((el) => el[0] === 'e' && el[1] !== id); + if (tags.length > 0) { + tags.forEach((tag) => { + const rootIndex = events.findIndex((el) => el.id === tag[1]); + if (rootIndex !== -1) { + const rootEvent = events[rootIndex]; + if (rootEvent && rootEvent.replies) { + rootEvent.replies.push(event); + } else { + rootEvent.replies = [event]; + } + replies.add(event.id); + } + }); + } + }); + const cleanEvents = events.filter((ev) => !replies.has(ev.id)); + return cleanEvents; + } + + return events; + } + + public async getInfiniteEvents({ + filter, + limit, + pageParam = 0, + signal = undefined, + }: { + filter: NDKFilter; + limit: number; + pageParam?: number; + signal?: AbortSignal; + }) { + const rootIds = new Set(); + const dedupQueue = new Set(); + + const events = await this.#fetcher.fetchLatestEvents(this.relays, filter, limit, { + asOf: pageParam === 0 ? undefined : pageParam, + abortSignal: signal, + }); + + const ndkEvents = events.map((event) => { + return new NDKEvent(this.#ndk, event); + }); + + ndkEvents.forEach((event) => { + const tags = event.tags.filter((el) => el[0] === 'e'); + if (tags && tags.length > 0) { + const rootId = tags.filter((el) => el[3] === 'root')[1] ?? tags[0][1]; + if (rootIds.has(rootId)) return dedupQueue.add(event.id); + rootIds.add(rootId); + } + }); + + return ndkEvents + .filter((event) => !dedupQueue.has(event.id)) + .sort((a, b) => b.created_at - a.created_at); + } + + /** + * Upload media file to nostr.build + * @todo support multiple backends + */ + public async upload({ fileExts }: { fileExts?: string[] }) { + const defaultExts = ['png', 'jpeg', 'jpg', 'gif'].concat(fileExts); + + const selected = await open({ + multiple: false, + filters: [ + { + name: 'Image', + extensions: defaultExts, + }, + ], + }); + + if (!selected) return null; + + const file = await readBinaryFile(selected.path); + const blob = new Blob([file]); + + const data = new FormData(); + data.append('fileToUpload', blob); + data.append('submit', 'Upload Image'); + + const res = await fetch('https://nostr.build/api/v2/upload/files', { + method: 'POST', + body: data, + }); + + if (!res.ok) return null; + + const json = await res.json(); + const content = json.data[0]; + + return content.url as string; + } + + public async validateNIP05({ + pubkey, + nip05, + signal, + }: { + pubkey: string; + nip05: string; + signal?: AbortSignal; + }) { + const localPath = nip05.split('@')[0]; + const service = nip05.split('@')[1]; + const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`; + + const res = await fetch(verifyURL, { + method: 'GET', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + signal, + }); + + if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`); + + const data: NIP05 = await res.json(); + if (data.names) { + if (data.names[localPath.toLowerCase()] !== pubkey) return false; + if (data.names[localPath] !== pubkey) return false; + return true; + } + return false; + } +} diff --git a/src/libs/ark/cache.ts b/src/libs/ark/cache.ts new file mode 100644 index 00000000..e563e7ba --- /dev/null +++ b/src/libs/ark/cache.ts @@ -0,0 +1,539 @@ +// inspired by NDK Cache Dexie +// source: https://github.com/nostr-dev-kit/ndk/tree/master/ndk-cache-dexie +import { + Hexpubkey, + NDKCacheAdapter, + NDKEvent, + NDKFilter, + NDKRelay, + NDKSubscription, + NDKUserProfile, + profileFromEvent, +} from '@nostr-dev-kit/ndk'; +import Database from '@tauri-apps/plugin-sql'; +import { LRUCache } from 'lru-cache'; +import { NostrEvent } from 'nostr-fetch'; +import { matchFilter } from 'nostr-tools'; + +import { NDKCacheEvent, NDKCacheEventTag, NDKCacheUser } from '@utils/types'; + +export class NDKCacheAdapterTauri implements NDKCacheAdapter { + #db: Database; + private dirtyProfiles: Set = new Set(); + public profiles?: LRUCache; + readonly locking: boolean; + + constructor(db: Database) { + this.#db = db; + this.locking = true; + + this.profiles = new LRUCache({ + max: 100000, + }); + + setInterval(() => { + this.dumpProfiles(); + }, 1000 * 10); + } + + async #getCacheUser(pubkey: string) { + const results: Array = 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 = 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 = 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 = 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 = 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 = 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 = 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) { + 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 { + Promise.allSettled( + subscription.filters.map((filter) => this.processFilter(filter, subscription)) + ); + } + + public async fetchProfile(pubkey: Hexpubkey) { + if (!this.profiles) return null; + + let profile = this.profiles.get(pubkey); + + if (!profile) { + const user = await this.#getCacheUser(pubkey); + if (user) { + profile = user.profile as NDKUserProfile; + this.profiles.set(pubkey, profile); + } + } + + return profile; + } + + public saveProfile(pubkey: Hexpubkey, profile: NDKUserProfile) { + if (!this.profiles) return; + + this.profiles.set(pubkey, profile); + + this.dirtyProfiles.add(pubkey); + } + + private async processFilter( + filter: NDKFilter, + subscription: NDKSubscription + ): Promise { + const _filter = { ...filter }; + delete _filter.limit; + const filterKeys = Object.keys(_filter || {}).sort(); + + try { + (await this.byKindAndAuthor(filterKeys, filter, subscription)) || + (await this.byAuthors(filterKeys, filter, subscription)) || + (await this.byKinds(filterKeys, filter, subscription)) || + (await this.byIdsQuery(filterKeys, filter, subscription)) || + (await this.byNip33Query(filterKeys, filter, subscription)) || + (await this.byTagsAndOptionallyKinds(filterKeys, filter, subscription)); + } catch (error) { + console.error(error); + } + } + + public async setEvent( + event: NDKEvent, + _filter: NDKFilter, + relay?: NDKRelay + ): Promise { + if (event.kind === 0) { + if (!this.profiles) return; + + const profile: NDKUserProfile = profileFromEvent(event); + this.profiles.set(event.pubkey, profile); + } else { + let addEvent = true; + + if (event.isParamReplaceable()) { + const replaceableId = `${event.kind}:${event.pubkey}:${event.tagId()}`; + const existingEvent = await this.#getCacheEvent(replaceableId); + if ( + existingEvent && + event.created_at && + existingEvent.createdAt > event.created_at + ) { + addEvent = false; + } + } + + if (addEvent) { + this.#setCacheEvent({ + id: event.tagId(), + pubkey: event.pubkey, + content: event.content, + kind: event.kind!, + createdAt: event.created_at!, + relay: relay?.url, + event: JSON.stringify(event.rawEvent()), + }); + + // Don't cache contact lists as tags since it's expensive + // and there is no use case for it + if (event.kind !== 3) { + event.tags.forEach((tag) => { + if (tag[0].length !== 1) return; + + this.#setCacheEventTag({ + id: `${event.id}:${tag[0]}:${tag[1]}`, + eventId: event.id, + tag: tag[0], + value: tag[1], + tagValue: tag[0] + tag[1], + }); + }); + } + } + } + } + + /** + * Searches by authors + */ + private async byAuthors( + filterKeys: string[], + filter: NDKFilter, + subscription: NDKSubscription + ): Promise { + const f = ['authors']; + const hasAllKeys = + filterKeys.length === f.length && f.every((k) => filterKeys.includes(k)); + + let foundEvents = false; + + if (hasAllKeys && filter.authors) { + for (const pubkey of filter.authors) { + const events = await this.#getCacheEventsByPubkey(pubkey); + for (const event of events) { + let rawEvent: NostrEvent; + try { + rawEvent = JSON.parse(event.event); + } catch (e) { + console.log('failed to parse event', e); + continue; + } + + const ndkEvent = new NDKEvent(undefined, rawEvent); + const relay = event.relay ? new NDKRelay(event.relay) : undefined; + subscription.eventReceived(ndkEvent, relay, true); + foundEvents = true; + } + } + } + return foundEvents; + } + + /** + * Searches by kinds + */ + private async byKinds( + filterKeys: string[], + filter: NDKFilter, + subscription: NDKSubscription + ): Promise { + const f = ['kinds']; + const hasAllKeys = + filterKeys.length === f.length && f.every((k) => filterKeys.includes(k)); + + let foundEvents = false; + + if (hasAllKeys && filter.kinds) { + for (const kind of filter.kinds) { + const events = await this.#getCacheEventsByKind(kind); + for (const event of events) { + let rawEvent: NostrEvent; + try { + rawEvent = JSON.parse(event.event); + } catch (e) { + console.log('failed to parse event', e); + continue; + } + + const ndkEvent = new NDKEvent(undefined, rawEvent); + const relay = event.relay ? new NDKRelay(event.relay) : undefined; + subscription.eventReceived(ndkEvent, relay, true); + foundEvents = true; + } + } + } + return foundEvents; + } + + /** + * Searches by ids + */ + private async byIdsQuery( + filterKeys: string[], + filter: NDKFilter, + subscription: NDKSubscription + ): Promise { + const f = ['ids']; + const hasAllKeys = + filterKeys.length === f.length && f.every((k) => filterKeys.includes(k)); + + if (hasAllKeys && filter.ids) { + for (const id of filter.ids) { + const event = await this.#getCacheEvent(id); + if (!event) continue; + + let rawEvent: NostrEvent; + try { + rawEvent = JSON.parse(event.event); + } catch (e) { + console.log('failed to parse event', e); + continue; + } + + const ndkEvent = new NDKEvent(undefined, rawEvent); + const relay = event.relay ? new NDKRelay(event.relay) : undefined; + subscription.eventReceived(ndkEvent, relay, true); + } + + return true; + } + + return false; + } + + /** + * Searches by NIP-33 + */ + private async byNip33Query( + filterKeys: string[], + filter: NDKFilter, + subscription: NDKSubscription + ): Promise { + const f = ['#d', 'authors', 'kinds']; + const hasAllKeys = + filterKeys.length === f.length && f.every((k) => filterKeys.includes(k)); + + if (hasAllKeys && filter.kinds && filter.authors) { + for (const kind of filter.kinds) { + const replaceableKind = kind >= 30000 && kind < 40000; + + if (!replaceableKind) continue; + + for (const author of filter.authors) { + for (const dTag of filter['#d']) { + const replaceableId = `${kind}:${author}:${dTag}`; + const event = await this.#getCacheEvent(replaceableId); + if (!event) continue; + + let rawEvent: NostrEvent; + try { + rawEvent = JSON.parse(event.event); + } catch (e) { + console.log('failed to parse event', e); + continue; + } + + const ndkEvent = new NDKEvent(undefined, rawEvent); + const relay = event.relay ? new NDKRelay(event.relay) : undefined; + subscription.eventReceived(ndkEvent, relay, true); + } + } + } + return true; + } + return false; + } + + /** + * Searches by kind & author + */ + private async byKindAndAuthor( + filterKeys: string[], + filter: NDKFilter, + subscription: NDKSubscription + ): Promise { + const f = ['authors', 'kinds']; + const hasAllKeys = + filterKeys.length === f.length && f.every((k) => filterKeys.includes(k)); + let foundEvents = false; + + if (!hasAllKeys) return false; + + if (filter.kinds && filter.authors) { + for (const kind of filter.kinds) { + for (const author of filter.authors) { + const events = await this.#getCacheEventsByKindAndAuthor(kind, author); + + for (const event of events) { + let rawEvent: NostrEvent; + try { + rawEvent = JSON.parse(event.event); + } catch (e) { + console.log('failed to parse event', e); + continue; + } + + const ndkEvent = new NDKEvent(undefined, rawEvent); + const relay = event.relay ? new NDKRelay(event.relay) : undefined; + subscription.eventReceived(ndkEvent, relay, true); + foundEvents = true; + } + } + } + } + return foundEvents; + } + + /** + * Searches by tags and optionally filters by tags + */ + private async byTagsAndOptionallyKinds( + filterKeys: string[], + filter: NDKFilter, + subscription: NDKSubscription + ): Promise { + for (const filterKey of filterKeys) { + const isKind = filterKey === 'kinds'; + const isTag = filterKey.startsWith('#') && filterKey.length === 2; + + if (!isKind && !isTag) return false; + } + + const events = await this.filterByTag(filterKeys, filter); + const kinds = filter.kinds as number[]; + + for (const event of events) { + if (!kinds?.includes(event.kind!)) continue; + + subscription.eventReceived(event, undefined, true); + } + + return false; + } + + private async filterByTag( + filterKeys: string[], + filter: NDKFilter + ): Promise { + const retEvents: NDKEvent[] = []; + + for (const filterKey of filterKeys) { + if (filterKey.length !== 2) continue; + const tag = filterKey.slice(1); + // const values = filter[filterKey] as string[]; + const values: string[] = []; + for (const [key, value] of Object.entries(filter)) { + if (key === filterKey) values.push(value as string); + } + + for (const value of values) { + const eventTags = await this.#getCacheEventTagsByTagValue(tag + value); + if (!eventTags.length) continue; + + const eventIds = eventTags.map((t) => t.eventId); + + const events = await this.#getCacheEvents(eventIds); + for (const event of events) { + let rawEvent; + try { + rawEvent = JSON.parse(event.event); + + // Make sure all passed filters match the event + if (!matchFilter(filter, rawEvent)) continue; + } catch (e) { + console.log('failed to parse event', e); + continue; + } + + const ndkEvent = new NDKEvent(undefined, rawEvent); + const relay = event.relay ? new NDKRelay(event.relay) : undefined; + ndkEvent.relay = relay; + retEvents.push(ndkEvent); + } + } + } + + return retEvents; + } + + private async dumpProfiles(): Promise { + const profiles = []; + + if (!this.profiles) return; + + for (const pubkey of this.dirtyProfiles) { + const profile = this.profiles.get(pubkey); + + if (!profile) continue; + + profiles.push({ + pubkey, + profile: JSON.stringify(profile), + createdAt: Date.now(), + }); + } + + if (profiles.length) { + await this.#setCacheProfiles(profiles); + } + + this.dirtyProfiles.clear(); + } +} diff --git a/src/libs/ark/index.ts b/src/libs/ark/index.ts new file mode 100644 index 00000000..39970415 --- /dev/null +++ b/src/libs/ark/index.ts @@ -0,0 +1,3 @@ +export * from './ark'; +export * from './cache'; +export * from './provider'; diff --git a/src/libs/ark/provider.tsx b/src/libs/ark/provider.tsx new file mode 100644 index 00000000..6f1290fa --- /dev/null +++ b/src/libs/ark/provider.tsx @@ -0,0 +1,123 @@ +import { ask } from '@tauri-apps/plugin-dialog'; +import { relaunch } from '@tauri-apps/plugin-process'; +import Database from '@tauri-apps/plugin-sql'; +import { check } from '@tauri-apps/plugin-updater'; +import Markdown from 'markdown-to-jsx'; +import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react'; + +import { Ark } from '@libs/ark'; + +import { LoaderIcon } from '@shared/icons'; + +import { QUOTES } from '@utils/constants'; + +interface ArkContext { + ark: Ark; +} + +const ArkContext = createContext({ + ark: undefined, +}); + +const ArkProvider = ({ children }: PropsWithChildren) => { + const [ark, setArk] = useState(undefined); + const [isNewVersion, setIsNewVersion] = useState(false); + + async function initArk() { + try { + const sqlite = await Database.load('sqlite:lume_v2.db'); + const _ark = new Ark({ storage: sqlite }); + + if (!_ark.account) await _ark.getActiveAccount(); + + const settings = await _ark.getAllSettings(); + let autoUpdater = false; + + if (settings) { + settings.forEach((item) => { + if (item.key === 'outbox') _ark.settings.outbox = !!parseInt(item.value); + + if (item.key === 'media') _ark.settings.media = !!parseInt(item.value); + + if (item.key === 'hashtag') _ark.settings.hashtag = !!parseInt(item.value); + + if (item.key === 'autoupdate') { + if (parseInt(item.value)) autoUpdater = true; + } + }); + } + + if (autoUpdater) { + // check update + const update = await check(); + // install new version + if (update) { + setIsNewVersion(true); + + await update.downloadAndInstall(); + await relaunch(); + } + } + + setArk(_ark); + } catch (e) { + console.error(e); + const yes = await ask(`${e}. Click "Yes" to relaunch app`, { + title: 'Lume', + type: 'error', + okLabel: 'Yes', + }); + if (yes) relaunch(); + } + } + + useEffect(() => { + if (!ark && !isNewVersion) initArk(); + }, []); + + if (!ark) { + return ( +
+
+
TIP:
+ + {QUOTES[Math.floor(Math.random() * QUOTES.length)]} + +
+
+ +

+ {isNewVersion ? 'Found a new version, updating...' : 'Starting...'} +

+
+
+ ); + } + + return {children}; +}; + +const useArk = () => { + const context = useContext(ArkContext); + if (context === undefined) { + throw new Error('Please import Ark Provider to use useArk() hook'); + } + return context; +}; + +export { ArkProvider, useArk }; diff --git a/src/main.jsx b/src/main.jsx index df781e5c..1dc6099d 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -3,8 +3,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { createRoot } from 'react-dom/client'; import { Toaster } from 'sonner'; -import { NDKProvider } from '@libs/ndk/provider'; -import { StorageProvider } from '@libs/storage/provider'; +import { ArkProvider } from '@libs/ark/provider'; import App from './app'; @@ -23,10 +22,8 @@ root.render( - - - - - + + + ); diff --git a/src/shared/accounts/logout.tsx b/src/shared/accounts/logout.tsx index 259ee1f9..9edd3d2e 100644 --- a/src/shared/accounts/logout.tsx +++ b/src/shared/accounts/logout.tsx @@ -3,26 +3,18 @@ import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; -import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; export function Logout() { - const { db } = useStorage(); - const { ndk } = useNDK(); + const { ark } = useArk(); const navigate = useNavigate(); const queryClient = useQueryClient(); const logout = async () => { try { - ndk.signer = null; - - // remove private key - await db.secureRemove(db.account.pubkey); - await db.secureRemove(`${db.account.pubkey}-nsecbunker`); - // logout - await db.accountLogout(); + await ark.logout(); // clear cache queryClient.clear(); diff --git a/src/shared/notes/repost.tsx b/src/shared/notes/repost.tsx index 8317732c..dbab01ae 100644 --- a/src/shared/notes/repost.tsx +++ b/src/shared/notes/repost.tsx @@ -2,7 +2,7 @@ import { NDKEvent, NDKKind, NostrEvent } from '@nostr-dev-kit/ndk'; import { useQuery } from '@tanstack/react-query'; import { memo } from 'react'; -import { useNDK } from '@libs/ndk/provider'; +import { useArk } from '@libs/ark'; import { MemoizedArticleKind, @@ -14,24 +14,25 @@ import { import { User } from '@shared/user'; export function Repost({ event }: { event: NDKEvent }) { - const { ndk } = useNDK(); + const { ark } = useArk(); const { status, data: repostEvent } = useQuery({ queryKey: ['repost', event.id], queryFn: async () => { try { + let event: NDKEvent = undefined; + if (event.content.length > 50) { const embed = JSON.parse(event.content) as NostrEvent; - const embedEvent = new NDKEvent(ndk, embed); - return embedEvent; + event = ark.createNDKEvent({ event: embed }); } const id = event.tags.find((el) => el[0] === 'e')[1]; if (!id) throw new Error('Failed to get repost event id'); - const ndkEvent = await ndk.fetchEvent(id); - if (!ndkEvent) return Promise.reject(new Error('Failed to get repost event')); + event = await ark.getEventById({ id }); - return ndkEvent; + if (!event) return Promise.reject(new Error('Failed to get repost event')); + return event; } catch { throw new Error('Failed to get repost event'); } diff --git a/src/shared/widgets/other/liveUpdater.tsx b/src/shared/widgets/other/liveUpdater.tsx index 60fe1250..5fa72578 100644 --- a/src/shared/widgets/other/liveUpdater.tsx +++ b/src/shared/widgets/other/liveUpdater.tsx @@ -1,16 +1,13 @@ -import { NDKEvent, NDKFilter, NDKKind, NDKSubscription } from '@nostr-dev-kit/ndk'; +import { NDKEvent, NDKKind, NDKSubscription } from '@nostr-dev-kit/ndk'; import { QueryStatus, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; -import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; import { ChevronUpIcon } from '@shared/icons'; export function LiveUpdater({ status }: { status: QueryStatus }) { - const { db } = useStorage(); - const { ndk } = useNDK(); - + const { ark } = useArk(); const [events, setEvents] = useState([]); const queryClient = useQueryClient(); @@ -30,19 +27,16 @@ export function LiveUpdater({ status }: { status: QueryStatus }) { useEffect(() => { let sub: NDKSubscription = undefined; - if (status === 'success' && db.account && db.account?.follows?.length > 0) { - queryClient.fetchQuery({ queryKey: ['notification'] }); - - const filter: NDKFilter = { - kinds: [NDKKind.Text, NDKKind.Repost], - authors: db.account.contacts, - since: Math.floor(Date.now() / 1000), - }; - - sub = ndk.subscribe(filter, { closeOnEose: false, groupable: false }); - sub.addListener('event', (event: NDKEvent) => - setEvents((prev) => [...prev, event]) - ); + if (status === 'success' && ark.account && ark.account?.contacts?.length > 0) { + sub = ark.subscribe({ + filter: { + kinds: [NDKKind.Text, NDKKind.Repost], + authors: ark.account.contacts, + since: Math.floor(Date.now() / 1000), + }, + closeOnEose: false, + cb: (event: NDKEvent) => setEvents((prev) => [...prev, event]), + }); } return () => { diff --git a/src/shared/widgets/other/nostrBandUserProfile.tsx b/src/shared/widgets/other/nostrBandUserProfile.tsx index a9395f64..37b30f5c 100644 --- a/src/shared/widgets/other/nostrBandUserProfile.tsx +++ b/src/shared/widgets/other/nostrBandUserProfile.tsx @@ -1,10 +1,8 @@ -import { NDKUser } from '@nostr-dev-kit/ndk'; import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; -import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; import { FollowIcon } from '@shared/icons'; @@ -16,9 +14,7 @@ export interface Profile { } export function NostrBandUserProfile({ data }: { data: Profile }) { - const { db } = useStorage(); - const { ndk } = useNDK(); - + const { ark } = useArk(); const [followed, setFollowed] = useState(false); const navigate = useNavigate(); @@ -26,12 +22,10 @@ export function NostrBandUserProfile({ data }: { data: Profile }) { const follow = async (pubkey: string) => { try { - if (!ndk.signer) return navigate('/new/privkey'); + if (!ark.readyToSign) return navigate('/new/privkey'); setFollowed(true); - const user = ndk.getUser({ pubkey: db.account.pubkey }); - const contacts = await user.follows(); - const add = await user.follow(new NDKUser({ pubkey: pubkey }), contacts); + const add = ark.createContact({ pubkey }); if (!add) { toast.success('You already follow this user'); @@ -44,7 +38,7 @@ export function NostrBandUserProfile({ data }: { data: Profile }) { }; useEffect(() => { - if (db.account.contacts.includes(data.pubkey)) { + if (ark.account.contacts.includes(data.pubkey)) { setFollowed(true); } }, []); diff --git a/src/utils/hooks/useEvent.ts b/src/utils/hooks/useEvent.ts index 7617aba3..36309c21 100644 --- a/src/utils/hooks/useEvent.ts +++ b/src/utils/hooks/useEvent.ts @@ -1,45 +1,41 @@ -import { NDKEvent, NDKSubscriptionCacheUsage, NostrEvent } from '@nostr-dev-kit/ndk'; +import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk'; import { useQuery } from '@tanstack/react-query'; import { nip19 } from 'nostr-tools'; import { AddressPointer } from 'nostr-tools/lib/types/nip19'; -import { useNDK } from '@libs/ndk/provider'; +import { useArk } from '@libs/ark'; export function useEvent(id: undefined | string, embed?: undefined | string) { - const { ndk } = useNDK(); + const { ark } = useArk(); const { status, isFetching, isError, data } = useQuery({ queryKey: ['event', id], queryFn: async () => { + let event: NDKEvent = undefined; + const naddr = id.startsWith('naddr') ? (nip19.decode(id).data as AddressPointer) : null; // return event refer from naddr if (naddr) { - const rEvents = await ndk.fetchEvents({ - kinds: [naddr.kind], - '#d': [naddr.identifier], - authors: [naddr.pubkey], + const events = await ark.getAllEvents({ + filter: { + kinds: [naddr.kind], + '#d': [naddr.identifier], + authors: [naddr.pubkey], + }, }); - - const rEvent = [...rEvents].slice(-1)[0]; - - if (!rEvent) throw new Error('event not found'); - return rEvent; + event = events.slice(-1)[0]; } // return embed event (nostr.band api) if (embed) { const embedEvent: NostrEvent = JSON.parse(embed); - const ndkEvent = new NDKEvent(ndk, embedEvent); - - return ndkEvent; + event = ark.createNDKEvent({ event: embedEvent }); } // get event from relay - const event = await ndk.fetchEvent(id, { - cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST, - }); + event = await ark.getEventById({ id }); if (!event) throw new Error(`Cannot get event with ${id}, will be retry after 10 seconds`); diff --git a/src/utils/hooks/useProfile.ts b/src/utils/hooks/useProfile.ts index c9b10ac9..c8e93758 100644 --- a/src/utils/hooks/useProfile.ts +++ b/src/utils/hooks/useProfile.ts @@ -1,11 +1,11 @@ -import { NDKSubscriptionCacheUsage, NDKUserProfile } from '@nostr-dev-kit/ndk'; +import { NDKUserProfile } from '@nostr-dev-kit/ndk'; import { useQuery } from '@tanstack/react-query'; import { nip19 } from 'nostr-tools'; -import { useNDK } from '@libs/ndk/provider'; +import { useArk } from '@libs/ark'; export function useProfile(pubkey: string, embed?: string) { - const { ndk } = useNDK(); + const { ark } = useArk(); const { isLoading, isError, @@ -29,10 +29,7 @@ export function useProfile(pubkey: string, embed?: string) { if (decoded.type === 'npub') hexstring = decoded.data; } - const user = ndk.getUser({ pubkey: hexstring }); - const profile = await user.fetchProfile({ - cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST, - }); + const profile = await ark.getUserProfile({ pubkey: hexstring }); if (!profile) throw new Error( diff --git a/src/utils/hooks/useRelay.ts b/src/utils/hooks/useRelay.ts index e95e1755..d60d0750 100644 --- a/src/utils/hooks/useRelay.ts +++ b/src/utils/hooks/useRelay.ts @@ -1,46 +1,44 @@ -import { NDKEvent, NDKKind, NDKRelayUrl, NDKTag } from '@nostr-dev-kit/ndk'; +import { NDKKind, NDKRelayUrl, NDKTag } from '@nostr-dev-kit/ndk'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useNDK } from '@libs/ndk/provider'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; export function useRelay() { - const { db } = useStorage(); - const { ndk } = useNDK(); - + const { ark } = useArk(); const queryClient = useQueryClient(); const connectRelay = useMutation({ mutationFn: async (relay: NDKRelayUrl, purpose?: 'read' | 'write' | undefined) => { // Cancel any outgoing refetches - await queryClient.cancelQueries({ queryKey: ['relays', db.account.pubkey] }); + await queryClient.cancelQueries({ queryKey: ['relays', ark.account.pubkey] }); // Snapshot the previous value const prevRelays: NDKTag[] = queryClient.getQueryData([ 'relays', - db.account.pubkey, + ark.account.pubkey, ]); // create new relay list if not exist if (!prevRelays) { - const newListEvent = new NDKEvent(ndk); - newListEvent.kind = NDKKind.RelayList; - newListEvent.tags = [['r', relay, purpose ?? '']]; - await newListEvent.publish(); + await ark.createEvent({ + kind: NDKKind.RelayList, + tags: [['r', relay, purpose ?? '']], + publish: true, + }); } // add relay to exist list const index = prevRelays.findIndex((el) => el[1] === relay); if (index > -1) return; - const event = new NDKEvent(ndk); - event.kind = NDKKind.RelayList; - event.tags = [...prevRelays, ['r', relay, purpose ?? '']]; - - await event.publish(); + await ark.createEvent({ + kind: NDKKind.RelayList, + tags: [...prevRelays, ['r', relay, purpose ?? '']], + publish: true, + }); // Optimistically update to the new value - queryClient.setQueryData(['relays', db.account.pubkey], (prev: NDKTag[]) => [ + queryClient.setQueryData(['relays', ark.account.pubkey], (prev: NDKTag[]) => [ ...prev, ['r', relay, purpose ?? ''], ]); @@ -49,19 +47,19 @@ export function useRelay() { return { prevRelays }; }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['relays', db.account.pubkey] }); + queryClient.invalidateQueries({ queryKey: ['relays', ark.account.pubkey] }); }, }); const removeRelay = useMutation({ mutationFn: async (relay: NDKRelayUrl) => { // Cancel any outgoing refetches - await queryClient.cancelQueries({ queryKey: ['relays', db.account.pubkey] }); + await queryClient.cancelQueries({ queryKey: ['relays', ark.account.pubkey] }); // Snapshot the previous value const prevRelays: NDKTag[] = queryClient.getQueryData([ 'relays', - db.account.pubkey, + ark.account.pubkey, ]); if (!prevRelays) return; @@ -69,19 +67,20 @@ export function useRelay() { const index = prevRelays.findIndex((el) => el[1] === relay); if (index > -1) prevRelays.splice(index, 1); - const event = new NDKEvent(ndk); - event.kind = NDKKind.RelayList; - event.tags = prevRelays; - await event.publish(); + await ark.createEvent({ + kind: NDKKind.RelayList, + tags: prevRelays, + publish: true, + }); // Optimistically update to the new value - queryClient.setQueryData(['relays', db.account.pubkey], prevRelays); + queryClient.setQueryData(['relays', ark.account.pubkey], prevRelays); // Return a context object with the snapshotted value return { prevRelays }; }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['relays', db.account.pubkey] }); + queryClient.invalidateQueries({ queryKey: ['relays', ark.account.pubkey] }); }, }); diff --git a/src/utils/hooks/useRichContent.tsx b/src/utils/hooks/useRichContent.tsx index 4edeefd1..f407aae6 100644 --- a/src/utils/hooks/useRichContent.tsx +++ b/src/utils/hooks/useRichContent.tsx @@ -4,7 +4,7 @@ import { ReactNode } from 'react'; import { Link } from 'react-router-dom'; import reactStringReplace from 'react-string-replace'; -import { useStorage } from '@libs/storage/provider'; +import { useArk } from '@libs/ark'; import { Hashtag, @@ -46,7 +46,7 @@ const VIDEOS = [ ]; export function useRichContent(content: string, textmode: boolean = false) { - const { db } = useStorage(); + const { ark } = useArk(); let parsedContent: string | ReactNode[] = content.replace(/\n+/g, '\n'); let linkPreview: string; @@ -58,7 +58,7 @@ export function useRichContent(content: string, textmode: boolean = false) { const words = text.split(/( |\n)/); if (!textmode) { - if (db.settings.media) { + if (ark.settings.media) { images = words.filter((word) => IMAGES.some((el) => word.endsWith(el))); videos = words.filter((word) => VIDEOS.some((el) => word.endsWith(el))); } @@ -90,7 +90,7 @@ export function useRichContent(content: string, textmode: boolean = false) { if (hashtags.length) { hashtags.forEach((hashtag) => { parsedContent = reactStringReplace(parsedContent, hashtag, (match, i) => { - if (db.settings.hashtag) return ; + if (ark.settings.hashtag) return ; return null; }); }); diff --git a/src/utils/types.d.ts b/src/utils/types.d.ts index 2b9874ac..cccaf9af 100644 --- a/src/utils/types.d.ts +++ b/src/utils/types.d.ts @@ -160,3 +160,9 @@ export interface NIP11 { payments_url: string; icon: string[]; } + +export interface NIP05 { + names: { + [key: string]: string; + }; +}