This commit is contained in:
reya 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 { 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) {

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 { 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;
});

View File

@ -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<string>('');
const [nsec, setNsec] = useState<string>('');
const [pubkey, setPubkey] = useState<undefined | string>(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() {
<p className="text-sm">
Lume will put your private key to{' '}
<b>
{db.platform === 'macos'
{ark.platform === 'macos'
? 'Apple Keychain (macOS)'
: db.platform === 'windows'
: ark.platform === 'windows'
? 'Credential Manager (Windows)'
: 'Secret Service (Linux)'}
</b>

View File

@ -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 (

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 { 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() {
<ChatMessage
key={message.id}
message={message}
isSelf={message.pubkey === db.account.pubkey}
isSelf={message.pubkey === ark.account.pubkey}
/>
);
},
@ -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 () => {

View File

@ -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 (
<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">

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 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);

View File

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

View File

@ -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.`);

View File

@ -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) {

View File

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

View File

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

View File

@ -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) => (
<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"
>
<User pubkey={item.pubkey} variant="simple" />
<User pubkey={item} variant="simple" />
</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 { 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();

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 { 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(
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
<Toaster position="top-center" theme="system" closeButton />
<StorageProvider>
<NDKProvider>
<App />
</NDKProvider>
</StorageProvider>
<ArkProvider>
<App />
</ArkProvider>
</QueryClientProvider>
);

View File

@ -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();

View File

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

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 { 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<NDKEvent[]>([]);
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 () => {

View File

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

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 { 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`);

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 { 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(

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 { 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] });
},
});

View File

@ -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 <Hashtag key={match + i} tag={hashtag} />;
if (ark.settings.hashtag) return <Hashtag key={match + i} tag={hashtag} />;
return null;
});
});

View File

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