This commit is contained in:
2023-12-07 11:50:25 +07:00
parent a42a2788ea
commit 95124e5ded
28 changed files with 1547 additions and 301 deletions

View File

@@ -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 { downloadDir } from '@tauri-apps/api/path';
import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { save } from '@tauri-apps/plugin-dialog'; import { save } from '@tauri-apps/plugin-dialog';
@@ -11,8 +11,7 @@ import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader'; import { AvatarUploader } from '@shared/avatarUploader';
import { ArrowLeftIcon, InfoIcon, LoaderIcon } from '@shared/icons'; import { ArrowLeftIcon, InfoIcon, LoaderIcon } from '@shared/icons';
@@ -29,13 +28,12 @@ export function CreateAccountScreen() {
privkey: string; privkey: string;
}>(null); }>(null);
const { ark } = useArk();
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { isDirty, isValid }, formState: { isDirty, isValid },
} = useForm(); } = useForm();
const { db } = useStorage();
const { ndk } = useNDK();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -62,28 +60,22 @@ export function CreateAccountScreen() {
const userNsec = nip19.nsecEncode(userPrivkey); const userNsec = nip19.nsecEncode(userPrivkey);
const signer = new NDKPrivateKeySigner(userPrivkey); const signer = new NDKPrivateKeySigner(userPrivkey);
ndk.signer = signer; ark.updateNostrSigner({ signer });
const event = new NDKEvent(ndk); const publish = await ark.createEvent({
event.content = JSON.stringify(profile); content: JSON.stringify(profile),
event.kind = NDKKind.Metadata; kind: NDKKind.Metadata,
event.pubkey = userPubkey; tags: [],
event.tags = []; publish: true,
});
const publish = await event.publish();
if (publish) { if (publish) {
await db.createAccount(userNpub, userPubkey); await ark.createAccount(userNpub, userPubkey, userPrivkey);
await db.secureSave(userPubkey, userPrivkey); await ark.createEvent({
kind: NDKKind.RelayList,
const relayListEvent = new NDKEvent(ndk); tags: [ark.relays],
relayListEvent.kind = NDKKind.RelayList; publish: true,
relayListEvent.tags = [...ndk.pool.relays.values()].map((item) => [ });
'r',
item.url,
]);
await relayListEvent.publish();
setKeys({ setKeys({
npub: userNpub, npub: userNpub,
@@ -93,7 +85,7 @@ export function CreateAccountScreen() {
}); });
setLoading(false); setLoading(false);
} else { } else {
toast('Create account failed'); toast('Cannot publish user profile, please try again later.');
setLoading(false); setLoading(false);
} }
} catch (e) { } catch (e) {

View File

@@ -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 * 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';
@@ -7,8 +7,7 @@ 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 { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { useStorage } from '@libs/storage/provider';
import { import {
ArrowLeftIcon, ArrowLeftIcon,
@@ -37,8 +36,7 @@ const POPULAR_USERS = [
const LUME_USERS = ['npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445']; const LUME_USERS = ['npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445'];
export function FollowScreen() { export function FollowScreen() {
const { ndk } = useNDK(); const { ark } = useArk();
const { db } = useStorage();
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['trending-profiles-widget'], queryKey: ['trending-profiles-widget'],
queryFn: async () => { queryFn: async () => {
@@ -68,16 +66,16 @@ export function FollowScreen() {
setLoading(true); setLoading(true);
if (!follows.length) return navigate('/auth/finish'); if (!follows.length) return navigate('/auth/finish');
const event = new NDKEvent(ndk); const publish = await ark.createEvent({
event.kind = NDKKind.Contacts; kind: NDKKind.Contacts,
event.tags = follows.map((item) => { tags: follows.map((item) => {
if (item.startsWith('npub')) return ['p', nip19.decode(item).data as string]; if (item.startsWith('npub')) return ['p', nip19.decode(item).data as string];
return ['p', item]; return ['p', item];
}),
}); });
const publish = await event.publish();
if (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; if (item.startsWith('npub')) return nip19.decode(item).data as string;
return item; return item;
}); });

View File

@@ -8,16 +8,12 @@ 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 { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons'; import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user'; import { User } from '@shared/user';
export function ImportAccountScreen() { export function ImportAccountScreen() {
const { db } = useStorage();
const { ndk } = useNDK();
const [npub, setNpub] = useState<string>(''); const [npub, setNpub] = useState<string>('');
const [nsec, setNsec] = useState<string>(''); const [nsec, setNsec] = useState<string>('');
const [pubkey, setPubkey] = useState<undefined | string>(undefined); const [pubkey, setPubkey] = useState<undefined | string>(undefined);
@@ -25,6 +21,7 @@ export function ImportAccountScreen() {
const [created, setCreated] = useState({ ok: false, remote: false }); const [created, setCreated] = useState({ ok: false, remote: false });
const [savedPrivkey, setSavedPrivkey] = useState(false); const [savedPrivkey, setSavedPrivkey] = useState(false);
const { ark } = useArk();
const navigate = useNavigate(); const navigate = useNavigate();
const submitNpub = async () => { const submitNpub = async () => {
@@ -47,8 +44,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 db.createSetting('nsecbunker', '1'); await ark.createSetting('nsecbunker', '1');
await db.secureSave(`${pubkey}-nsecbunker`, localSigner.privateKey); await ark.createPrivkey(`${pubkey}-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');
@@ -60,8 +57,7 @@ export function ImportAccountScreen() {
const remoteSigner = new NDKNip46Signer(bunker, npub, localSigner); const remoteSigner = new NDKNip46Signer(bunker, npub, localSigner);
await remoteSigner.blockUntilReady(); await remoteSigner.blockUntilReady();
ark.updateNostrSigner({ signer: remoteSigner });
ndk.signer = remoteSigner;
setPubkey(pubkey); setPubkey(pubkey);
setCreated({ ok: false, remote: true }); setCreated({ ok: false, remote: true });
@@ -80,14 +76,10 @@ export function ImportAccountScreen() {
setLoading(true); setLoading(true);
// add account to db // add account to db
await db.createAccount(npub, pubkey); await ark.createAccount(npub, pubkey);
// get account metadata // get account contacts
const user = ndk.getUser({ pubkey }); await ark.getUserContacts({ pubkey });
if (user) {
db.account.contacts = [...(await user.follows())].map((user) => user.pubkey);
db.account.relayList = await user.relayList();
}
setCreated((prev) => ({ ...prev, ok: true })); setCreated((prev) => ({ ...prev, ok: true }));
setLoading(false); setLoading(false);
@@ -109,9 +101,8 @@ 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 db.secureSave(pubkey, privkey); await ark.createPrivkey(pubkey, privkey);
ark.updateNostrSigner({ signer: new NDKPrivateKeySigner(privkey) });
ndk.signer = new NDKPrivateKeySigner(privkey);
setSavedPrivkey(true); setSavedPrivkey(true);
} catch (e) { } catch (e) {
@@ -290,9 +281,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>
{db.platform === 'macos' {ark.platform === 'macos'
? 'Apple Keychain (macOS)' ? 'Apple Keychain (macOS)'
: db.platform === 'windows' : ark.platform === 'windows'
? 'Credential Manager (Windows)' ? 'Credential Manager (Windows)'
: 'Secret Service (Linux)'} : 'Secret Service (Linux)'}
</b> </b>

View File

@@ -1,21 +1,23 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom'; 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 { EditIcon, ReactionIcon, ReplyIcon, RepostIcon, ZapIcon } from '@shared/icons';
import { TextNote } from '@shared/notes'; import { TextNote } from '@shared/notes';
export function TutorialNoteScreen() { export function TutorialNoteScreen() {
const { ndk } = useNDK(); const { ark } = useArk();
const exampleEvent = new NDKEvent(ndk, {
id: 'a3527670dd9b178bf7c2a9ea673b63bc8bfe774942b196691145343623c45821', const exampleEvent = ark.createNDKEvent({
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9', event: {
created_at: 1701355223, id: 'a3527670dd9b178bf7c2a9ea673b63bc8bfe774942b196691145343623c45821',
kind: 1, pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9',
tags: [], created_at: 1701355223,
content: 'good morning nostr, stay humble and stack sats 🫡', kind: 1,
sig: '9e0bd67ec25598744f20bff0fe360fdf190c4240edb9eea260e50f77e07f94ea767ececcc6270819b7f64e5e7ca1fe20b4971f46dc120e6db43114557f3a6dae', tags: [],
content: 'good morning nostr, stay humble and stack sats 🫡',
sig: '9e0bd67ec25598744f20bff0fe360fdf190c4240edb9eea260e50f77e07f94ea767ececcc6270819b7f64e5e7ca1fe20b4971f46dc120e6db43114557f3a6dae',
},
}); });
return ( return (

View File

@@ -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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@@ -7,8 +7,7 @@ import { VList, VListHandle } from 'virtua';
import { ChatForm } from '@app/chats/components/chatForm'; import { ChatForm } from '@app/chats/components/chatForm';
import { ChatMessage } from '@app/chats/components/message'; import { ChatMessage } from '@app/chats/components/message';
import { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { User } from '@shared/user'; import { User } from '@shared/user';
@@ -16,8 +15,7 @@ import { User } from '@shared/user';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
export function ChatScreen() { export function ChatScreen() {
const { db } = useStorage(); const { ark } = useArk();
const { ndk } = useNDK();
const { pubkey } = useParams(); const { pubkey } = useParams();
const { fetchNIP04Messages } = useNostr(); const { fetchNIP04Messages } = useNostr();
const { status, data } = useQuery({ const { status, data } = useQuery({
@@ -59,7 +57,7 @@ export function ChatScreen() {
<ChatMessage <ChatMessage
key={message.id} key={message.id}
message={message} message={message}
isSelf={message.pubkey === db.account.pubkey} isSelf={message.pubkey === ark.account.pubkey}
/> />
); );
}, },
@@ -71,20 +69,15 @@ export function ChatScreen() {
}, [data]); }, [data]);
useEffect(() => { useEffect(() => {
const sub: NDKSubscription = ndk.subscribe( const sub = ark.subscribe({
{ filter: {
kinds: [4], kinds: [4],
authors: [db.account.pubkey], authors: [ark.account.pubkey],
'#p': [pubkey], '#p': [pubkey],
since: Math.floor(Date.now() / 1000), since: Math.floor(Date.now() / 1000),
}, },
{ closeOnEose: false,
closeOnEose: false, cb: (event) => newMessage.mutate(event),
}
);
sub.addListener('event', (event) => {
newMessage.mutate(event);
}); });
return () => { return () => {

View File

@@ -1,20 +1,15 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect } from 'react'; import { useCallback } from 'react';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { ChatListItem } from '@app/chats/components/chatListItem'; import { ChatListItem } from '@app/chats/components/chatListItem';
import { useNDK } from '@libs/ndk/provider';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
export function ChatsScreen() { export function ChatsScreen() {
const navigate = useNavigate();
const { ndk } = useNDK();
const { getAllNIP04Chats } = useNostr(); const { getAllNIP04Chats } = useNostr();
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['nip04-chats'], queryKey: ['nip04-chats'],
@@ -34,10 +29,6 @@ export function ChatsScreen() {
[data] [data]
); );
useEffect(() => {
if (!ndk.signer) navigate('/new/privkey');
}, []);
return ( return (
<div className="grid h-full w-full grid-cols-3"> <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 className="col-span-1 h-full overflow-y-auto border-r border-neutral-200 scrollbar-none dark:border-neutral-800">

View File

@@ -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 CharacterCount from '@tiptap/extension-character-count';
import Image from '@tiptap/extension-image'; import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
@@ -12,7 +12,7 @@ import { Markdown } from 'tiptap-markdown';
import { ArticleCoverUploader, MediaUploader, MentionPopup } from '@app/new/components'; import { ArticleCoverUploader, MediaUploader, MentionPopup } from '@app/new/components';
import { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { import {
BoldIcon, BoldIcon,
@@ -25,7 +25,7 @@ import {
} from '@shared/icons'; } from '@shared/icons';
export function NewArticleScreen() { export function NewArticleScreen() {
const { ndk } = useNDK(); const { ark } = useArk();
const [height, setHeight] = useState(0); const [height, setHeight] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -69,7 +69,7 @@ export function NewArticleScreen() {
const submit = async () => { const submit = async () => {
try { try {
if (!ndk.signer) return navigate('/new/privkey'); if (!ark.readyToSign) return navigate('/new/privkey');
setLoading(true); setLoading(true);
@@ -91,16 +91,16 @@ export function NewArticleScreen() {
tags.push(['t', tag.replace('#', '')]); tags.push(['t', tag.replace('#', '')]);
}); });
const event = new NDKEvent(ndk);
event.content = content;
event.kind = NDKKind.Article;
event.tags = tags;
// publish // publish
const publishedRelays = await event.publish(); const publish = await ark.createEvent({
content,
tags,
kind: NDKKind.Article,
publish: true,
});
if (publishedRelays) { if (publish) {
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`); toast.success(`Broadcasted to ${publish} relays successfully.`);
// update state // update state
setLoading(false); setLoading(false);

View File

@@ -1,16 +1,15 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { message, open } from '@tauri-apps/plugin-dialog'; import { message, open } from '@tauri-apps/plugin-dialog';
import { readBinaryFile } from '@tauri-apps/plugin-fs'; import { readBinaryFile } from '@tauri-apps/plugin-fs';
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 { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
export function NewFileScreen() { export function NewFileScreen() {
const { ndk } = useNDK(); const { ark } = useArk();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -86,20 +85,21 @@ export function NewFileScreen() {
const submit = async () => { const submit = async () => {
try { try {
if (!ndk.signer) return navigate('/new/privkey'); if (!ark.readyToSign) return navigate('/new/privkey');
setIsPublish(true); setIsPublish(true);
const event = new NDKEvent(ndk); const publish = await ark.createEvent({
event.content = caption; kind: 1063,
event.kind = 1063; tags: metadata,
event.tags = metadata; content: caption,
publish: true,
});
const publishedRelays = await event.publish(); if (publish) {
if (publishedRelays) { toast.success(`Broadcasted to ${publish} relays successfully.`);
setMetadata(null); setMetadata(null);
setIsPublish(false); setIsPublish(false);
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
} }
} catch (e) { } catch (e) {
setIsPublish(false); setIsPublish(false);

View File

@@ -13,7 +13,7 @@ import { toast } from 'sonner';
import { MediaUploader, MentionPopup } from '@app/new/components'; 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 { CancelIcon, LoaderIcon } from '@shared/icons';
import { MentionNote } from '@shared/notes'; import { MentionNote } from '@shared/notes';
@@ -23,7 +23,7 @@ import { useSuggestion } from '@utils/hooks/useSuggestion';
import { useWidget } from '@utils/hooks/useWidget'; import { useWidget } from '@utils/hooks/useWidget';
export function NewPostScreen() { export function NewPostScreen() {
const { ndk } = useNDK(); const { ark } = useArk();
const { addWidget } = useWidget(); const { addWidget } = useWidget();
const { suggestion } = useSuggestion(); const { suggestion } = useSuggestion();
@@ -68,7 +68,7 @@ export function NewPostScreen() {
const submit = async () => { const submit = async () => {
try { try {
if (!ndk.signer) return navigate('/new/privkey'); if (!ark.readyToSign) return navigate('/new/privkey');
setLoading(true); setLoading(true);
@@ -81,7 +81,7 @@ export function NewPostScreen() {
], ],
}); });
const event = new NDKEvent(ndk); const event = new NDKEvent();
event.content = serializedContent; event.content = serializedContent;
event.kind = NDKKind.Text; event.kind = NDKKind.Text;
@@ -100,7 +100,10 @@ export function NewPostScreen() {
} }
// publish event // publish event
const publishedRelays = await event.publish(); const publishedRelays = await ark.createEvent({
kind: NDKKind.Text,
content: serializedContent,
});
if (publishedRelays) { if (publishedRelays) {
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`); toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);

View File

@@ -4,19 +4,13 @@ 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 { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { useStorage } from '@libs/storage/provider';
export function NewPrivkeyScreen() { export function NewPrivkeyScreen() {
const { db } = useStorage(); const { ark } = useArk();
const { ndk } = useNDK();
const [nsec, setNsec] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const save = async (content: string) => { const [nsec, setNsec] = useState('');
return await db.secureSave(db.account.pubkey, content);
};
const submit = async (isSave?: boolean) => { const submit = async (isSave?: boolean) => {
try { try {
@@ -30,15 +24,15 @@ export function NewPrivkeyScreen() {
const privkey = decoded.data; const privkey = decoded.data;
const pubkey = getPublicKey(privkey); const pubkey = getPublicKey(privkey);
if (pubkey !== db.account.pubkey) if (pubkey !== ark.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'
); );
const signer = new NDKPrivateKeySigner(privkey); 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); navigate(-1);
} catch (e) { } catch (e) {

View File

@@ -1,22 +1,19 @@
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 { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { useStorage } from '@libs/storage/provider';
import { EditIcon, LoaderIcon } from '@shared/icons'; import { EditIcon, LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/number'; import { compactNumber } from '@utils/number';
export function ContactCard() { export function ContactCard() {
const { db } = useStorage(); const { ark } = useArk();
const { ndk } = useNDK();
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['contacts'], queryKey: ['contacts'],
queryFn: async () => { queryFn: async () => {
const user = ndk.getUser({ pubkey: db.account.pubkey }); const contacts = await ark.getUserContacts({});
const follows = await user.follows(); return contacts;
return [...follows];
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });

View File

@@ -1,23 +1,18 @@
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 { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { useStorage } from '@libs/storage/provider';
import { EditIcon, LoaderIcon } from '@shared/icons'; import { EditIcon, LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/number'; import { compactNumber } from '@utils/number';
export function RelayCard() { export function RelayCard() {
const { db } = useStorage(); const { ark } = useArk();
const { ndk } = useNDK();
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['relays'], queryKey: ['relays', ark.account.pubkey],
queryFn: async () => { queryFn: async () => {
const user = ndk.getUser({ pubkey: db.account.pubkey }); const relays = await ark.getUserRelays({});
const relays = await user.relayList();
if (!relays) return Promise.reject(new Error("user's relay set not found"));
return relays; return relays;
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,

View File

@@ -1,21 +1,16 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { User } from '@shared/user'; import { User } from '@shared/user';
export function EditContactScreen() { export function EditContactScreen() {
const { db } = useStorage(); const { ark } = useArk();
const { ndk } = useNDK();
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['contacts'], queryKey: ['contacts'],
queryFn: async () => { queryFn: async () => {
const user = ndk.getUser({ pubkey: db.account.pubkey }); return await ark.getUserContacts({});
const follows = await user.follows();
return [...follows];
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
@@ -29,10 +24,10 @@ export function EditContactScreen() {
) : ( ) : (
data.map((item) => ( data.map((item) => (
<div <div
key={item.pubkey} key={item}
className="flex h-16 w-full items-center justify-between rounded-xl bg-neutral-100 px-2.5 dark:bg-neutral-900" className="flex h-16 w-full items-center justify-between rounded-xl bg-neutral-100 px-2.5 dark:bg-neutral-900"
> >
<User pubkey={item.pubkey} variant="simple" /> <User pubkey={item} variant="simple" />
</div> </div>
)) ))
)} )}

View File

@@ -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 { useQueryClient } from '@tanstack/react-query';
import { message } from '@tauri-apps/plugin-dialog'; 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 { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { useStorage } from '@libs/storage/provider';
import { CheckCircleIcon, LoaderIcon, PlusIcon, UnverifiedIcon } from '@shared/icons'; import { CheckCircleIcon, LoaderIcon, PlusIcon, UnverifiedIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
export function EditProfileScreen() { export function EditProfileScreen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState(''); const [picture, setPicture] = useState('');
const [banner, setBanner] = useState(''); const [banner, setBanner] = useState('');
const [nip05, setNIP05] = useState({ verified: true, text: '' }); const [nip05, setNIP05] = useState({ verified: true, text: '' });
const { db } = useStorage(); const { ark } = useArk();
const { ndk } = useNDK();
const { upload } = useNostr();
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -32,7 +24,7 @@ export function EditProfileScreen() {
formState: { isValid, errors }, formState: { isValid, errors },
} = useForm({ } = useForm({
defaultValues: async () => { defaultValues: async () => {
const res: NDKUserProfile = queryClient.getQueryData(['user', db.account.pubkey]); const res: NDKUserProfile = queryClient.getQueryData(['user', ark.account.pubkey]);
if (res.image) { if (res.image) {
setPicture(res.image); setPicture(res.image);
} }
@@ -46,13 +38,16 @@ export function EditProfileScreen() {
}, },
}); });
const queryClient = useQueryClient();
const navigate = useNavigate();
const uploadAvatar = async () => { const uploadAvatar = async () => {
try { try {
if (!ndk.signer) return navigate('/new/privkey'); if (!ark.readyToSign) return navigate('/new/privkey');
setLoading(true); setLoading(true);
const image = await upload(); const image = await ark.upload({});
if (image) { if (image) {
setPicture(image); setPicture(image);
setLoading(false); setLoading(false);
@@ -67,7 +62,7 @@ export function EditProfileScreen() {
try { try {
setLoading(true); setLoading(true);
const image = await upload(); const image = await ark.upload({});
if (image) { if (image) {
setBanner(image); setBanner(image);
@@ -83,7 +78,7 @@ export function EditProfileScreen() {
// start loading // start loading
setLoading(true); setLoading(true);
const content = { let content = {
...data, ...data,
username: data.name, username: data.name,
display_name: data.name, display_name: data.name,
@@ -91,15 +86,10 @@ export function EditProfileScreen() {
image: data.picture, image: data.picture,
}; };
const event = new NDKEvent(ndk);
event.kind = NDKKind.Metadata;
event.tags = [];
if (data.nip05) { if (data.nip05) {
const user = ndk.getUser({ pubkey: db.account.pubkey }); const verify = ark.validateNIP05({ pubkey: ark.account.pubkey, nip05: data.nip05 });
const verify = await user.validateNip05(data.nip05);
if (verify) { if (verify) {
event.content = JSON.stringify({ ...content, nip05: data.nip05 }); content = { ...content, nip05: data.nip05 };
} else { } else {
setNIP05((prev) => ({ ...prev, verified: false })); setNIP05((prev) => ({ ...prev, verified: false }));
setError('nip05', { setError('nip05', {
@@ -107,16 +97,19 @@ export function EditProfileScreen() {
message: "Can't verify your Lume ID / NIP-05, please check again", 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 // invalid cache
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['user', db.account.pubkey], queryKey: ['user', ark.account.pubkey],
}); });
// reset form // reset form
reset(); reset();

661
src/libs/ark/ark.ts Normal file
View File

@@ -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<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) {
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<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);',
[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<Widget> = 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<Widget> = 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;
}
}

539
src/libs/ark/cache.ts Normal file
View File

@@ -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<Hexpubkey> = new Set();
public profiles?: LRUCache<Hexpubkey, NDKUserProfile>;
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<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> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<NDKEvent[]> {
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<void> {
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();
}
}

3
src/libs/ark/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './ark';
export * from './cache';
export * from './provider';

123
src/libs/ark/provider.tsx Normal file
View File

@@ -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<ArkContext>({
ark: undefined,
});
const ArkProvider = ({ children }: PropsWithChildren<object>) => {
const [ark, setArk] = useState<Ark>(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 (
<div
data-tauri-drag-region
className="relative flex h-screen w-screen items-center justify-center bg-neutral-50 dark:bg-neutral-950"
>
<div className="flex max-w-2xl flex-col items-start gap-1">
<h5 className="font-semibold uppercase">TIP:</h5>
<Markdown
options={{
overrides: {
a: {
props: {
className: 'text-blue-500 hover:text-blue-600',
target: '_blank',
},
},
},
}}
className="text-4xl font-semibold leading-snug text-neutral-300 dark:text-neutral-700"
>
{QUOTES[Math.floor(Math.random() * QUOTES.length)]}
</Markdown>
</div>
<div className="absolute bottom-5 right-5 inline-flex items-center gap-2.5">
<LoaderIcon className="h-6 w-6 animate-spin text-blue-500" />
<p className="font-semibold">
{isNewVersion ? 'Found a new version, updating...' : 'Starting...'}
</p>
</div>
</div>
);
}
return <ArkContext.Provider value={{ ark }}>{children}</ArkContext.Provider>;
};
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 };

View File

@@ -3,8 +3,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { NDKProvider } from '@libs/ndk/provider'; import { ArkProvider } from '@libs/ark/provider';
import { StorageProvider } from '@libs/storage/provider';
import App from './app'; import App from './app';
@@ -23,10 +22,8 @@ root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
<Toaster position="top-center" theme="system" closeButton /> <Toaster position="top-center" theme="system" closeButton />
<StorageProvider> <ArkProvider>
<NDKProvider> <App />
<App /> </ArkProvider>
</NDKProvider>
</StorageProvider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -3,26 +3,18 @@ import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { useStorage } from '@libs/storage/provider';
export function Logout() { export function Logout() {
const { db } = useStorage(); const { ark } = useArk();
const { ndk } = useNDK();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const logout = async () => { const logout = async () => {
try { try {
ndk.signer = null;
// remove private key
await db.secureRemove(db.account.pubkey);
await db.secureRemove(`${db.account.pubkey}-nsecbunker`);
// logout // logout
await db.accountLogout(); await ark.logout();
// clear cache // clear cache
queryClient.clear(); queryClient.clear();

View File

@@ -2,7 +2,7 @@ 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 { memo } from 'react';
import { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { import {
MemoizedArticleKind, MemoizedArticleKind,
@@ -14,24 +14,25 @@ import {
import { User } from '@shared/user'; import { User } from '@shared/user';
export function Repost({ event }: { event: NDKEvent }) { export function Repost({ event }: { event: NDKEvent }) {
const { ndk } = useNDK(); const { ark } = useArk();
const { status, data: repostEvent } = useQuery({ const { status, data: repostEvent } = useQuery({
queryKey: ['repost', event.id], queryKey: ['repost', event.id],
queryFn: async () => { queryFn: async () => {
try { try {
let event: NDKEvent = undefined;
if (event.content.length > 50) { if (event.content.length > 50) {
const embed = JSON.parse(event.content) as NostrEvent; const embed = JSON.parse(event.content) as NostrEvent;
const embedEvent = new NDKEvent(ndk, embed); event = ark.createNDKEvent({ event: embed });
return embedEvent;
} }
const id = event.tags.find((el) => el[0] === 'e')[1]; const id = event.tags.find((el) => el[0] === 'e')[1];
if (!id) throw new Error('Failed to get repost event id'); if (!id) throw new Error('Failed to get repost event id');
const ndkEvent = await ndk.fetchEvent(id); event = await ark.getEventById({ id });
if (!ndkEvent) return Promise.reject(new Error('Failed to get repost event'));
return ndkEvent; if (!event) return Promise.reject(new Error('Failed to get repost event'));
return event;
} catch { } catch {
throw new Error('Failed to get repost event'); throw new Error('Failed to get repost event');
} }

View File

@@ -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 { QueryStatus, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { useStorage } from '@libs/storage/provider';
import { ChevronUpIcon } from '@shared/icons'; import { ChevronUpIcon } from '@shared/icons';
export function LiveUpdater({ status }: { status: QueryStatus }) { export function LiveUpdater({ status }: { status: QueryStatus }) {
const { db } = useStorage(); const { ark } = useArk();
const { ndk } = useNDK();
const [events, setEvents] = useState<NDKEvent[]>([]); const [events, setEvents] = useState<NDKEvent[]>([]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -30,19 +27,16 @@ export function LiveUpdater({ status }: { status: QueryStatus }) {
useEffect(() => { useEffect(() => {
let sub: NDKSubscription = undefined; let sub: NDKSubscription = undefined;
if (status === 'success' && db.account && db.account?.follows?.length > 0) { if (status === 'success' && ark.account && ark.account?.contacts?.length > 0) {
queryClient.fetchQuery({ queryKey: ['notification'] }); sub = ark.subscribe({
filter: {
const filter: NDKFilter = { kinds: [NDKKind.Text, NDKKind.Repost],
kinds: [NDKKind.Text, NDKKind.Repost], authors: ark.account.contacts,
authors: db.account.contacts, since: Math.floor(Date.now() / 1000),
since: Math.floor(Date.now() / 1000), },
}; closeOnEose: false,
cb: (event: NDKEvent) => setEvents((prev) => [...prev, event]),
sub = ndk.subscribe(filter, { closeOnEose: false, groupable: false }); });
sub.addListener('event', (event: NDKEvent) =>
setEvents((prev) => [...prev, event])
);
} }
return () => { return () => {

View File

@@ -1,10 +1,8 @@
import { NDKUser } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { useStorage } from '@libs/storage/provider';
import { FollowIcon } from '@shared/icons'; import { FollowIcon } from '@shared/icons';
@@ -16,9 +14,7 @@ export interface Profile {
} }
export function NostrBandUserProfile({ data }: { data: Profile }) { export function NostrBandUserProfile({ data }: { data: Profile }) {
const { db } = useStorage(); const { ark } = useArk();
const { ndk } = useNDK();
const [followed, setFollowed] = useState(false); const [followed, setFollowed] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -26,12 +22,10 @@ export function NostrBandUserProfile({ data }: { data: Profile }) {
const follow = async (pubkey: string) => { const follow = async (pubkey: string) => {
try { try {
if (!ndk.signer) return navigate('/new/privkey'); if (!ark.readyToSign) return navigate('/new/privkey');
setFollowed(true); setFollowed(true);
const user = ndk.getUser({ pubkey: db.account.pubkey }); const add = ark.createContact({ pubkey });
const contacts = await user.follows();
const add = await user.follow(new NDKUser({ pubkey: pubkey }), contacts);
if (!add) { if (!add) {
toast.success('You already follow this user'); toast.success('You already follow this user');
@@ -44,7 +38,7 @@ export function NostrBandUserProfile({ data }: { data: Profile }) {
}; };
useEffect(() => { useEffect(() => {
if (db.account.contacts.includes(data.pubkey)) { if (ark.account.contacts.includes(data.pubkey)) {
setFollowed(true); setFollowed(true);
} }
}, []); }, []);

View File

@@ -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 { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { AddressPointer } from 'nostr-tools/lib/types/nip19'; 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) { export function useEvent(id: undefined | string, embed?: undefined | string) {
const { ndk } = useNDK(); const { ark } = useArk();
const { status, isFetching, isError, data } = useQuery({ const { status, isFetching, isError, data } = useQuery({
queryKey: ['event', id], queryKey: ['event', id],
queryFn: async () => { queryFn: async () => {
let event: NDKEvent = undefined;
const naddr = id.startsWith('naddr') const naddr = id.startsWith('naddr')
? (nip19.decode(id).data as AddressPointer) ? (nip19.decode(id).data as AddressPointer)
: null; : null;
// return event refer from naddr // return event refer from naddr
if (naddr) { if (naddr) {
const rEvents = await ndk.fetchEvents({ const events = await ark.getAllEvents({
kinds: [naddr.kind], filter: {
'#d': [naddr.identifier], kinds: [naddr.kind],
authors: [naddr.pubkey], '#d': [naddr.identifier],
authors: [naddr.pubkey],
},
}); });
event = events.slice(-1)[0];
const rEvent = [...rEvents].slice(-1)[0];
if (!rEvent) throw new Error('event not found');
return rEvent;
} }
// return embed event (nostr.band api) // return embed event (nostr.band api)
if (embed) { if (embed) {
const embedEvent: NostrEvent = JSON.parse(embed); const embedEvent: NostrEvent = JSON.parse(embed);
const ndkEvent = new NDKEvent(ndk, embedEvent); event = ark.createNDKEvent({ event: embedEvent });
return ndkEvent;
} }
// get event from relay // get event from relay
const event = await ndk.fetchEvent(id, { event = await ark.getEventById({ id });
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
});
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`);

View File

@@ -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 { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
export function useProfile(pubkey: string, embed?: string) { export function useProfile(pubkey: string, embed?: string) {
const { ndk } = useNDK(); const { ark } = useArk();
const { const {
isLoading, isLoading,
isError, isError,
@@ -29,10 +29,7 @@ export function useProfile(pubkey: string, embed?: string) {
if (decoded.type === 'npub') hexstring = decoded.data; if (decoded.type === 'npub') hexstring = decoded.data;
} }
const user = ndk.getUser({ pubkey: hexstring }); const profile = await ark.getUserProfile({ pubkey: hexstring });
const profile = await user.fetchProfile({
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
});
if (!profile) if (!profile)
throw new Error( throw new Error(

View File

@@ -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 { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNDK } from '@libs/ndk/provider'; import { useArk } from '@libs/ark';
import { useStorage } from '@libs/storage/provider';
export function useRelay() { export function useRelay() {
const { db } = useStorage(); const { ark } = useArk();
const { ndk } = useNDK();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const connectRelay = useMutation({ const connectRelay = useMutation({
mutationFn: async (relay: NDKRelayUrl, purpose?: 'read' | 'write' | undefined) => { mutationFn: async (relay: NDKRelayUrl, purpose?: 'read' | 'write' | undefined) => {
// Cancel any outgoing refetches // Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['relays', db.account.pubkey] }); await queryClient.cancelQueries({ queryKey: ['relays', ark.account.pubkey] });
// Snapshot the previous value // Snapshot the previous value
const prevRelays: NDKTag[] = queryClient.getQueryData([ const prevRelays: NDKTag[] = queryClient.getQueryData([
'relays', 'relays',
db.account.pubkey, ark.account.pubkey,
]); ]);
// create new relay list if not exist // create new relay list if not exist
if (!prevRelays) { if (!prevRelays) {
const newListEvent = new NDKEvent(ndk); await ark.createEvent({
newListEvent.kind = NDKKind.RelayList; kind: NDKKind.RelayList,
newListEvent.tags = [['r', relay, purpose ?? '']]; tags: [['r', relay, purpose ?? '']],
await newListEvent.publish(); publish: true,
});
} }
// add relay to exist list // add relay to exist list
const index = prevRelays.findIndex((el) => el[1] === relay); const index = prevRelays.findIndex((el) => el[1] === relay);
if (index > -1) return; if (index > -1) return;
const event = new NDKEvent(ndk); await ark.createEvent({
event.kind = NDKKind.RelayList; kind: NDKKind.RelayList,
event.tags = [...prevRelays, ['r', relay, purpose ?? '']]; tags: [...prevRelays, ['r', relay, purpose ?? '']],
publish: true,
await event.publish(); });
// Optimistically update to the new value // Optimistically update to the new value
queryClient.setQueryData(['relays', db.account.pubkey], (prev: NDKTag[]) => [ queryClient.setQueryData(['relays', ark.account.pubkey], (prev: NDKTag[]) => [
...prev, ...prev,
['r', relay, purpose ?? ''], ['r', relay, purpose ?? ''],
]); ]);
@@ -49,19 +47,19 @@ export function useRelay() {
return { prevRelays }; return { prevRelays };
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['relays', db.account.pubkey] }); queryClient.invalidateQueries({ queryKey: ['relays', ark.account.pubkey] });
}, },
}); });
const removeRelay = useMutation({ const removeRelay = useMutation({
mutationFn: async (relay: NDKRelayUrl) => { mutationFn: async (relay: NDKRelayUrl) => {
// Cancel any outgoing refetches // Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['relays', db.account.pubkey] }); await queryClient.cancelQueries({ queryKey: ['relays', ark.account.pubkey] });
// Snapshot the previous value // Snapshot the previous value
const prevRelays: NDKTag[] = queryClient.getQueryData([ const prevRelays: NDKTag[] = queryClient.getQueryData([
'relays', 'relays',
db.account.pubkey, ark.account.pubkey,
]); ]);
if (!prevRelays) return; if (!prevRelays) return;
@@ -69,19 +67,20 @@ export function useRelay() {
const index = prevRelays.findIndex((el) => el[1] === relay); const index = prevRelays.findIndex((el) => el[1] === relay);
if (index > -1) prevRelays.splice(index, 1); if (index > -1) prevRelays.splice(index, 1);
const event = new NDKEvent(ndk); await ark.createEvent({
event.kind = NDKKind.RelayList; kind: NDKKind.RelayList,
event.tags = prevRelays; tags: prevRelays,
await event.publish(); publish: true,
});
// Optimistically update to the new value // 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 a context object with the snapshotted value
return { prevRelays }; return { prevRelays };
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['relays', db.account.pubkey] }); queryClient.invalidateQueries({ queryKey: ['relays', ark.account.pubkey] });
}, },
}); });

View File

@@ -4,7 +4,7 @@ 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 { useStorage } from '@libs/storage/provider'; import { useArk } from '@libs/ark';
import { import {
Hashtag, Hashtag,
@@ -46,7 +46,7 @@ const VIDEOS = [
]; ];
export function useRichContent(content: string, textmode: boolean = false) { export function useRichContent(content: string, textmode: boolean = false) {
const { db } = useStorage(); const { ark } = useArk();
let parsedContent: string | ReactNode[] = content.replace(/\n+/g, '\n'); let parsedContent: string | ReactNode[] = content.replace(/\n+/g, '\n');
let linkPreview: string; let linkPreview: string;
@@ -58,7 +58,7 @@ export function useRichContent(content: string, textmode: boolean = false) {
const words = text.split(/( |\n)/); const words = text.split(/( |\n)/);
if (!textmode) { if (!textmode) {
if (db.settings.media) { if (ark.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)));
} }
@@ -90,7 +90,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 (db.settings.hashtag) return <Hashtag key={match + i} tag={hashtag} />; if (ark.settings.hashtag) return <Hashtag key={match + i} tag={hashtag} />;
return null; return null;
}); });
}); });

View File

@@ -160,3 +160,9 @@ export interface NIP11 {
payments_url: string; payments_url: string;
icon: string[]; icon: string[];
} }
export interface NIP05 {
names: {
[key: string]: string;
};
}