Merge pull request #130 from luminous-devs/feat/ark

Introduction Ark
This commit is contained in:
Ren Amamiya
2023-12-10 11:19:04 +07:00
committed by GitHub
101 changed files with 2743 additions and 3523 deletions

View File

@@ -1,5 +1,3 @@
/* @import 'reactflow/dist/style.css'; */
/* Vidstack */
@import '@vidstack/react/player/styles/default/theme.css';
@import '@vidstack/react/player/styles/default/layouts/video.css';

View File

@@ -1,13 +1,11 @@
import { message } from '@tauri-apps/plugin-dialog';
import { fetch } from '@tauri-apps/plugin-http';
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
import { ReactFlowProvider } from 'reactflow';
import { ChatsScreen } from '@app/chats';
import { ErrorScreen } from '@app/error';
import { ExploreScreen } from '@app/explore';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { AppLayout } from '@shared/layouts/app';
@@ -19,12 +17,12 @@ import { SettingsLayout } from '@shared/layouts/settings';
import './app.css';
export default function App() {
const { db } = useStorage();
const { ark } = useArk();
const accountLoader = async () => {
try {
// redirect to welcome screen if none user exist
const totalAccount = await db.checkAccount();
const totalAccount = await ark.checkAccount();
if (totalAccount === 0) return redirect('/auth/welcome');
return null;
@@ -87,15 +85,6 @@ export default function App() {
return { Component: RelayScreen };
},
},
{
path: 'explore',
element: (
<ReactFlowProvider>
<ExploreScreen />
</ReactFlowProvider>
),
errorElement: <ErrorScreen />,
},
{
path: 'chats',
element: <ChatsScreen />,

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,27 @@ 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);
await ark.createAccount({
id: userNpub,
pubkey: userPubkey,
privkey: 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.createEvent({
kind: NDKKind.RelayList,
tags: [ark.relays],
publish: true,
});
setKeys({
npub: userNpub,
@@ -93,7 +90,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,6 +1,81 @@
import { Link } from 'react-router-dom';
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { FETCH_LIMIT } from '@utils/constants';
export function FinishScreen() {
const { ark } = useArk();
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const navigate = useNavigate();
const prefetch = async () => {
if (!ark.account.contacts.length) return navigate('/');
try {
setLoading(true);
// prefetch newsfeed
await queryClient.prefetchInfiniteQuery({
queryKey: ['newsfeed'],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
return await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: !ark.account.contacts.length
? [ark.account.pubkey]
: ark.account.contacts,
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
},
});
// prefetch notification
await queryClient.prefetchInfiniteQuery({
queryKey: ['notification'],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
return await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
'#p': [ark.account.pubkey],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
},
});
navigate('/');
} catch (e) {
console.error(e);
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
@@ -17,12 +92,13 @@ export function FinishScreen() {
>
Start tutorial
</Link>
<Link
to="/"
<button
type="button"
onClick={prefetch}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Skip
</Link>
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Skip'}
</button>
<p className="text-center text-sm font-medium text-neutral-500 dark:text-neutral-600">
You need to restart app to make changes in previous step take effect or you
can continue with Lume default settings

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,20 +66,22 @@ 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('npub1')) return ['p', nip19.decode(item).data as string];
return ['p', item];
}),
publish: true,
});
const publish = await event.publish();
if (publish) {
db.account.contacts = follows.map((item) => {
if (item.startsWith('npub')) return nip19.decode(item).data as string;
ark.account.contacts = follows.map((item) => {
if (item.startsWith('npub1')) return nip19.decode(item).data as string;
return item;
});
setLoading(false);
return navigate('/auth/finish');
}
} catch (e) {

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(`${npub}-nsecbunker`, localSigner.privateKey);
await ark.createSetting('nsecbunker', '1');
await ark.createPrivkey(`${npub}-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({ id: 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

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

View File

@@ -1,6 +1,81 @@
import { Link } from 'react-router-dom';
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { FETCH_LIMIT } from '@utils/constants';
export function TutorialFinishScreen() {
const { ark } = useArk();
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const navigate = useNavigate();
const prefetch = async () => {
if (!ark.account.contacts.length) return navigate('/');
try {
setLoading(true);
// prefetch newsfeed
await queryClient.prefetchInfiniteQuery({
queryKey: ['newsfeed'],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
return await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: !ark.account.contacts.length
? [ark.account.pubkey]
: ark.account.contacts,
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
},
});
// prefetch notification
await queryClient.prefetchInfiniteQuery({
queryKey: ['notification'],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
return await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
'#p': [ark.account.pubkey],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
},
});
navigate('/');
} catch (e) {
console.error(e);
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
@@ -11,12 +86,17 @@ export function TutorialFinishScreen() {
</h1>
</div>
<div className="flex flex-col gap-2">
<Link
to="/"
<button
type="button"
onClick={prefetch}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Start using
</Link>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
'Start using Lume'
)}
</button>
<Link
to="https://nostr.how/"
target="_blank"

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,23 +7,18 @@ 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';
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({
queryKey: ['nip04-dm', pubkey],
queryFn: async () => {
return await fetchNIP04Messages(pubkey);
return await ark.getAllMessagesByPubkey({ pubkey });
},
refetchOnWindowFocus: false,
});
@@ -59,7 +54,7 @@ export function ChatScreen() {
<ChatMessage
key={message.id}
message={message}
isSelf={message.pubkey === db.account.pubkey}
isSelf={message.pubkey === ark.account.pubkey}
/>
);
},
@@ -71,20 +66,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,29 +1,19 @@
import { NDKEvent, NDKKind, NDKUser } from '@nostr-dev-kit/ndk';
import { useState } from 'react';
import { toast } from 'sonner';
import { MediaUploader } from '@app/chats/components/mediaUploader';
import { useNDK } from '@libs/ndk/provider';
import { useArk } from '@libs/ark';
import { EnterIcon } from '@shared/icons';
export function ChatForm({ receiverPubkey }: { receiverPubkey: string }) {
const { ndk } = useNDK();
const { ark } = useArk();
const [value, setValue] = useState('');
const submit = async () => {
try {
const recipient = new NDKUser({ pubkey: receiverPubkey });
const message = await ndk.signer.encrypt(recipient, value);
const event = new NDKEvent(ndk);
event.content = message;
event.kind = NDKKind.EncryptedDirectMessage;
event.tag(recipient);
const publish = await event.publish();
const publish = await ark.nip04Encrypt({ content: value, pubkey: receiverPubkey });
if (publish) setValue('');
} catch (e) {
toast.error(e);

View File

@@ -1,22 +1,24 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, MediaIcon } from '@shared/icons';
import { useArk } from '@libs/ark';
import { useNostr } from '@utils/hooks/useNostr';
import { LoaderIcon, MediaIcon } from '@shared/icons';
export function MediaUploader({
setState,
}: {
setState: Dispatch<SetStateAction<string>>;
}) {
const { upload } = useNostr();
const { ark } = useArk();
const [loading, setLoading] = useState(false);
const uploadMedia = async () => {
setLoading(true);
const image = await upload(['mp4', 'mp3', 'webm', 'mkv', 'avi', 'mov']);
const image = await ark.upload({
fileExts: ['mp4', 'mp3', 'webm', 'mkv', 'avi', 'mov'],
});
if (image) {
setState((prev: string) => `${prev}\n${image}`);

View File

@@ -1,26 +1,17 @@
import { NDKEvent, NDKUser } from '@nostr-dev-kit/ndk';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
export function useDecryptMessage(message: NDKEvent) {
const { db } = useStorage();
const { ndk } = useNDK();
const [content, setContent] = useState(message.content);
export function useDecryptMessage(event: NDKEvent) {
const { ark } = useArk();
const [content, setContent] = useState(event.content);
useEffect(() => {
async function decryptContent() {
try {
const sender = new NDKUser({
pubkey:
db.account.pubkey === message.pubkey
? message.tags.find((el) => el[0] === 'p')[1]
: message.pubkey,
});
const result = await ndk.signer.decrypt(sender, message.content);
setContent(result);
const message = await ark.nip04Decrypt({ event });
setContent(message);
} catch (e) {
console.error(e);
}

View File

@@ -1,25 +1,20 @@
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 { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
export function ChatsScreen() {
const navigate = useNavigate();
const { ndk } = useNDK();
const { getAllNIP04Chats } = useNostr();
const { ark } = useArk();
const { status, data } = useQuery({
queryKey: ['nip04-chats'],
queryFn: async () => {
return await getAllNIP04Chats();
return await ark.getAllChats();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
@@ -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

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

View File

@@ -1,29 +0,0 @@
import { BaseEdge, EdgeProps, getBezierPath } from 'reactflow';
export function Edge({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
}: EdgeProps) {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
return (
<BaseEdge
path={edgePath}
markerEnd={markerEnd}
style={{ ...style, stroke: '#71717a' }}
/>
);
}

View File

@@ -1,17 +0,0 @@
import { memo } from 'react';
import { useProfile } from '@utils/hooks/useProfile';
export const GroupTitle = memo(function GroupTitle({ pubkey }: { pubkey: string }) {
const { isLoading, user } = useProfile(pubkey);
if (isLoading) {
return <div className="h-3 w-24 animate-pulse rounded bg-white/10" />;
}
return (
<h3 className="text-sm font-semibold text-blue-500">{`${
user.name || user.display_name
}'s network`}</h3>
);
});

View File

@@ -1,14 +0,0 @@
export function Line({ fromX, fromY, toX, toY }) {
return (
<g>
<path
fill="none"
stroke="#f5d0fe"
strokeWidth={1.5}
className="animated"
d={`M${fromX},${fromY} C ${fromX} ${toY} ${fromX} ${toY} ${toX},${toY}`}
/>
<circle cx={toX} cy={toY} fill="#fff" r={3} stroke="#f5d0fe" strokeWidth={1.5} />
</g>
);
}

View File

@@ -1,34 +0,0 @@
import { Handle, Position } from 'reactflow';
import { UserWithDrawer } from '@app/explore/components/userWithDrawer';
import { GroupTitle } from './groupTitle';
export function UserGroupNode({ data }) {
return (
<>
<Handle
type="target"
position={Position.Top}
className="h-2 w-5 rounded-full border-none !bg-blue-400"
/>
<div className="relative mx-3 my-3 flex flex-col gap-1">
{data.title ? (
<h3 className="text-sm font-semibold text-blue-500">{data.title}</h3>
) : (
<GroupTitle pubkey={data.pubkey} />
)}
<div className="grid grid-cols-5 gap-6 rounded-lg border border-blue-500/50 bg-blue-500/10 p-4">
{data.list.map((user: string) => (
<UserWithDrawer key={user} pubkey={user} />
))}
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="h-2 w-5 rounded-full border-none !bg-blue-400"
/>
</>
);
}

View File

@@ -1,59 +0,0 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { LoaderIcon } from '@shared/icons';
import { MemoizedRepost, MemoizedTextNote, UnknownNote } from '@shared/notes';
import { useNostr } from '@utils/hooks/useNostr';
export function UserLatestPosts({ pubkey }: { pubkey: string }) {
const { getEventsByPubkey } = useNostr();
const { status, data } = useQuery({
queryKey: ['user-posts', pubkey],
queryFn: async () => {
return await getEventsByPubkey(pubkey);
},
refetchOnWindowFocus: false,
});
const renderItem = useCallback(
(event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <MemoizedTextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <MemoizedRepost key={event.id} event={event} />;
default:
return <UnknownNote key={event.id} event={event} />;
}
},
[data]
);
return (
<div className="mt-4 border-t border-neutral-300 pt-3 dark:border-neutral-700">
<h3 className="mb-4 px-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Latest post
</h3>
<div>
{status === 'pending' ? (
<div className="px-3">
<div className="inline-flex h-16 w-full items-center justify-center gap-1.5 rounded-lg bg-neutral-300 text-sm font-medium dark:bg-neutral-700">
<LoaderIcon className="h-4 w-4 animate-spin" />
Loading latest posts...
</div>
</div>
) : data.length < 1 ? (
<div className="px-3">
<div className="inline-flex h-16 w-full items-center justify-center rounded-lg bg-neutral-300 text-sm font-medium dark:bg-neutral-700">
No posts from 24 hours ago
</div>
</div>
) : (
data.map((event) => renderItem(event))
)}
</div>
</div>
);
}

View File

@@ -1,21 +0,0 @@
import { Handle, Position } from 'reactflow';
import { User } from '@shared/user';
export function UserNode({ data }) {
return (
<>
<div className="relative mx-3 my-3 inline-flex h-12 w-12 shrink-0 items-center justify-center">
<span className="absolute inline-flex h-8 w-8 animate-ping rounded-lg bg-green-400 opacity-75"></span>
<div className="relative z-10">
<User pubkey={data.pubkey} variant="avatar" />
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="h-2 w-2 rounded-full border-none !bg-white/20"
/>
</>
);
}

View File

@@ -1,155 +0,0 @@
import { NDKEvent, NDKKind, NDKUser } from '@nostr-dev-kit/ndk';
import * as Dialog from '@radix-ui/react-dialog';
import { memo, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { NIP05 } from '@shared/nip05';
import { TextNote } from '@shared/notes';
import { User } from '@shared/user';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
import { UserLatestPosts } from './userLatestPosts';
export const UserWithDrawer = memo(function UserWithDrawer({
pubkey,
}: {
pubkey: string;
}) {
const { db } = useStorage();
const { ndk } = useNDK();
const { isLoading, user } = useProfile(pubkey);
const [followed, setFollowed] = useState(false);
const follow = async (pubkey: string) => {
try {
const user = ndk.getUser({ pubkey: db.account.pubkey });
const contacts = await user.follows();
const add = await user.follow(new NDKUser({ pubkey: pubkey }), contacts);
if (add) {
setFollowed(true);
} else {
toast('You already follow this user');
}
} catch (error) {
console.log(error);
}
};
const unfollow = async (pubkey: string) => {
try {
const user = ndk.getUser({ pubkey: db.account.pubkey });
const contacts = await user.follows();
contacts.delete(new NDKUser({ pubkey: pubkey }));
let list: string[][];
contacts.forEach((el) => list.push(['p', el.pubkey, el.relayUrls?.[0] || '', '']));
const event = new NDKEvent(ndk);
event.content = '';
event.kind = NDKKind.Contacts;
event.tags = list;
const publishedRelays = await event.publish();
if (publishedRelays) {
setFollowed(false);
}
} catch (error) {
console.log(error);
}
};
useEffect(() => {
if (db.account.contacts.includes(pubkey)) {
setFollowed(true);
}
}, []);
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button type="button">
<User pubkey={pubkey} variant="avatar" />
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content className="fixed right-0 top-0 z-50 flex h-full w-[400px] animate-slideRightAndFade items-center justify-center px-4 pb-4 pt-16 transition-all">
<div className="h-full w-full overflow-y-auto rounded-lg border border-neutral-300 bg-neutral-200 py-3 dark:border-neutral-700 dark:bg-neutral-800">
{isLoading ? (
<div>
<p>Loading...</p>
</div>
) : (
<>
<div className="flex flex-col gap-3 px-3">
<img
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-12 w-12 rounded-lg"
/>
<div className="flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-1.5">
<div>
<h5 className="text-lg font-semibold">
{user?.name || user?.display_name || user?.displayName}
</h5>
{user?.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user?.nip05}
className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400"
/>
) : (
<span className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400">
{displayNpub(pubkey, 16)}
</span>
)}
</div>
{user?.about ? <TextNote content={user?.about} /> : null}
</div>
<div className="inline-flex items-center gap-2">
{followed ? (
<button
type="button"
onClick={() => unfollow(pubkey)}
className="inline-flex h-9 w-36 items-center justify-center rounded-lg bg-neutral-300 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-700"
>
Unfollow
</button>
) : (
<button
type="button"
onClick={() => follow(pubkey)}
className="inline-flex h-9 w-36 items-center justify-center rounded-lg bg-neutral-300 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-700"
>
Follow
</button>
)}
<Link
to={`/chats/${pubkey}`}
className="inline-flex h-9 w-36 items-center justify-center rounded-lg bg-neutral-300 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-700"
>
Message
</Link>
</div>
</div>
</div>
<UserLatestPosts pubkey={pubkey} />
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
});

View File

@@ -1,116 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import ReactFlow, {
Background,
ConnectionMode,
addEdge,
useEdgesState,
useNodesState,
useReactFlow,
} from 'reactflow';
import { Edge } from '@app/explore/components/edge';
import { Line } from '@app/explore/components/line';
import { UserGroupNode } from '@app/explore/components/userGroupNode';
import { UserNode } from '@app/explore/components/userNode';
import { useStorage } from '@libs/storage/provider';
import { useNostr } from '@utils/hooks/useNostr';
import { getMultipleRandom } from '@utils/transform';
let id = 2;
const getId = () => `${id++}`;
const nodeTypes = { user: UserNode, userGroup: UserGroupNode };
const edgeTypes = { buttonedge: Edge };
export function ExploreScreen() {
const { db } = useStorage();
const { getContactsByPubkey } = useNostr();
const { project } = useReactFlow();
const defaultContacts = useMemo(() => getMultipleRandom(db.account.contacts, 10), []);
const reactFlowWrapper = useRef(null);
const connectingNodeId = useRef(null);
const initialNodes = [
{
id: '0',
type: 'user',
position: { x: 141, y: 0 },
data: { list: [], title: '', pubkey: db.account.pubkey },
},
{
id: '1',
type: 'userGroup',
position: { x: 0, y: 200 },
data: { list: defaultContacts, title: 'Starting Point', pubkey: '' },
},
];
const initialEdges = [{ id: 'e0-1', type: 'buttonedge', source: '0', target: '1' }];
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), []);
const onConnectStart = useCallback((_, { nodeId }) => {
connectingNodeId.current = nodeId;
}, []);
const onConnectEnd = useCallback(
async (event) => {
const targetIsPane = event.target.classList.contains('react-flow__pane');
if (targetIsPane) {
const { top, left } = reactFlowWrapper.current.getBoundingClientRect();
const id = getId();
const prevData = nodes.slice(-1)[0];
const randomPubkey = getMultipleRandom(prevData.data.list, 1)[0];
const newContactList = await getContactsByPubkey(randomPubkey);
const newNode = {
id,
type: 'userGroup',
position: project({ x: event.clientX - left, y: event.clientY - top }),
data: { list: newContactList, title: null, pubkey: randomPubkey },
};
setNodes((nds) => nds.concat(newNode));
setEdges((eds) =>
eds.concat({
id,
type: 'buttonedge',
source: connectingNodeId.current,
target: id,
})
);
}
},
[project]
);
return (
<div className="h-full w-full" ref={reactFlowWrapper}>
<ReactFlow
proOptions={{ hideAttribution: true }}
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineComponent={Line}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
connectionMode={ConnectionMode.Loose}
minZoom={0.8}
maxZoom={1.2}
fitView
>
<Background color="#3f3f46" />
</ReactFlow>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { useCallback, useRef, useState } from 'react';
import { VList, VListHandle } from 'virtua';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import {
@@ -28,11 +28,11 @@ export function HomeScreen() {
const ref = useRef<VListHandle>(null);
const [selectedIndex, setSelectedIndex] = useState(-1);
const { db } = useStorage();
const { ark } = useArk();
const { status, data } = useQuery({
queryKey: ['widgets'],
queryFn: async () => {
const dbWidgets = await db.getWidgets();
const dbWidgets = await ark.getWidgets();
const defaultWidgets = [
{
id: '9999',

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

@@ -2,12 +2,12 @@ import { message } from '@tauri-apps/plugin-dialog';
import { Editor } from '@tiptap/react';
import { useState } from 'react';
import { useArk } from '@libs/ark';
import { MediaIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
export function MediaUploader({ editor }: { editor: Editor }) {
const { upload } = useNostr();
const { ark } = useArk();
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => {
@@ -15,7 +15,9 @@ export function MediaUploader({ editor }: { editor: Editor }) {
// start loading
setLoading(true);
const image = await upload(['mp4', 'mp3', 'webm', 'mkv', 'avi', 'mov']);
const image = await ark.upload({
fileExts: ['mp4', 'mp3', 'webm', 'mkv', 'avi', 'mov'],
});
if (image) {
editor.commands.setImage({ src: image });

View File

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

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

@@ -7,13 +7,13 @@ import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { convert } from 'html-to-text';
import { nip19 } from 'nostr-tools';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
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,29 +81,23 @@ export function NewPostScreen() {
],
});
const event = new NDKEvent(ndk);
event.content = serializedContent;
event.kind = NDKKind.Text;
// add reply to tags if present
const replyTo = searchParams.get('replyTo');
const rootReplyTo = searchParams.get('rootReplyTo');
if (rootReplyTo) {
const rootEvent = await ndk.fetchEvent(rootReplyTo);
event.tag(rootEvent, 'root');
}
if (replyTo) {
const replyEvent = await ndk.fetchEvent(replyTo);
event.tag(replyEvent, 'reply');
}
// publish event
const publishedRelays = await event.publish();
const event = (await ark.createEvent({
kind: NDKKind.Text,
tags: [],
content: serializedContent,
replyTo,
rootReplyTo,
})) as NDKEvent;
if (publishedRelays) {
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
const publish = await event.publish();
if (publish) {
toast.success(`Broadcasted to ${publish.size} relays successfully.`);
// update state
setLoading(false);
@@ -132,10 +126,6 @@ export function NewPostScreen() {
setHeight(containerRef.current.clientHeight);
}, []);
useEffect(() => {
if (editor) editor.commands.focus('end');
}, [editor]);
return (
<div className="flex flex-1 flex-col gap-4">
<div className="flex-1 overflow-y-auto">

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) {
@@ -68,14 +62,14 @@ export function NewPrivkeyScreen() {
<button
type="button"
onClick={() => submit()}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Submit
</button>
<button
type="button"
onClick={() => submit(true)}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
Submit and Save
</button>

View File

@@ -6,6 +6,8 @@ import { useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import { useArk } from '@libs/ark';
import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons';
import {
ChildNote,
@@ -18,15 +20,14 @@ import { ReplyList } from '@shared/notes/replies/list';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
import { useNostr } from '@utils/hooks/useNostr';
export function TextNoteScreen() {
const navigate = useNavigate();
const replyRef = useRef(null);
const { id } = useParams();
const { ark } = useArk();
const { status, data } = useEvent(id);
const { getEventThread } = useNostr();
const [isCopy, setIsCopy] = useState(false);
@@ -50,7 +51,7 @@ export function TextNoteScreen() {
};
const renderKind = (event: NDKEvent) => {
const thread = getEventThread(event.tags);
const thread = ark.getEventThread({ tags: event.tags });
switch (event.kind) {
case NDKKind.Text:
return (

View File

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

View File

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

View File

@@ -1,33 +1,58 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { normalizeRelayUrl } from 'nostr-fetch';
import { useCallback } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { VList } from 'virtua';
import { useNDK } from '@libs/ndk/provider';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { MemoizedRepost, MemoizedTextNote, UnknownNote } from '@shared/notes';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import {
MemoizedRepost,
MemoizedTextNote,
NoteSkeleton,
UnknownNote,
} from '@shared/notes';
import { FETCH_LIMIT } from '@utils/constants';
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
const { fetcher } = useNDK();
const { status, data } = useQuery({
queryKey: ['relay-events', relayUrl],
queryFn: async () => {
const url = 'wss://' + relayUrl;
const events = await fetcher.fetchLatestEvents(
[normalizeRelayUrl(url)],
{
kinds: [NDKKind.Text, NDKKind.Repost],
},
20
);
return events as unknown as NDKEvent[];
},
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
const { ark } = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['relay-events', relayUrl],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const url = 'wss://' + relayUrl;
const events = await ark.getRelayEvents({
relayUrl: url,
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
refetchOnWindowFocus: false,
});
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data]
);
const renderItem = useCallback(
(event: NDKEvent) => {
@@ -46,16 +71,33 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
return (
<VList className="mx-auto h-full w-full max-w-[500px] pt-10 scrollbar-none">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
<p className="text-sm font-medium text-white/80">Loading newsfeed...</p>
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div>
) : (
data.map((item) => renderItem(item))
allEvents.map((item) => renderItem(item))
)}
<div className="h-20" />
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
);
}

View File

@@ -2,19 +2,20 @@ import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { VList } from 'virtua';
import { useArk } from '@libs/ark';
import { LoaderIcon, PlusIcon, ShareIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useNostr } from '@utils/hooks/useNostr';
import { useRelay } from '@utils/hooks/useRelay';
export function RelayList() {
const { getAllRelaysByUsers } = useNostr();
const { ark } = useArk();
const { connectRelay } = useRelay();
const { status, data } = useQuery({
queryKey: ['relays'],
queryFn: async () => {
return await getAllRelaysByUsers();
return await ark.getAllRelaysFromContacts();
},
refetchOnWindowFocus: false,
refetchOnMount: false,

View File

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

View File

@@ -1,10 +1,10 @@
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
export function AdvancedSettingScreen() {
const { db } = useStorage();
const { ark } = useArk();
const clearCache = async () => {
await db.clearCache();
await ark.clearCache();
};
return (

View File

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

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

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

View File

@@ -2,7 +2,7 @@ import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import { EditIcon, LoaderIcon } from '@shared/icons';
@@ -10,12 +10,12 @@ import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function ProfileCard() {
const { db } = useStorage();
const { isLoading, user } = useProfile(db.account.pubkey);
const { ark } = useArk();
const { isLoading, user } = useProfile(ark.account.pubkey);
const svgURI =
'data:image/svg+xml;utf8,' +
encodeURIComponent(minidenticon(db.account.pubkey, 90, 50));
encodeURIComponent(minidenticon(ark.account.pubkey, 90, 50));
return (
<div className="mb-4 h-56 w-full rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
@@ -38,7 +38,7 @@ export function ProfileCard() {
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={db.account.pubkey}
alt={ark.account.pubkey}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
@@ -47,7 +47,7 @@ export function ProfileCard() {
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={db.account.pubkey}
alt={ark.account.pubkey}
className="h-16 w-16 rounded-xl bg-black dark:bg-white"
/>
</Avatar.Fallback>
@@ -57,7 +57,7 @@ export function ProfileCard() {
{user?.display_name || user?.name}
</h3>
<p className="text-lg text-neutral-700 dark:text-neutral-300">
{user?.nip05 || displayNpub(db.account.pubkey, 16)}
{user?.nip05 || displayNpub(ark.account.pubkey, 16)}
</p>
</div>
</div>

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

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

View File

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

View File

@@ -1,4 +1,3 @@
import { NDKEvent, NDKKind, NDKUser } from '@nostr-dev-kit/ndk';
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useEffect, useState } from 'react';
@@ -7,8 +6,7 @@ import { toast } from 'sonner';
import { UserStats } from '@app/users/components/stats';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import { NIP05 } from '@shared/nip05';
@@ -16,8 +14,7 @@ import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function UserProfile({ pubkey }: { pubkey: string }) {
const { db } = useStorage();
const { ndk } = useNDK();
const { ark } = useArk();
const { user } = useProfile(pubkey);
const [followed, setFollowed] = useState(false);
@@ -28,12 +25,10 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
const follow = async () => {
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 = await ark.createContact({ pubkey });
if (!add) {
toast.success('You already follow this user');
@@ -47,32 +42,17 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
const unfollow = async () => {
try {
if (!ndk.signer) return navigate('/new/privkey');
if (!ark.readyToSign) return navigate('/new/privkey');
setFollowed(false);
const user = ndk.getUser({ pubkey: db.account.pubkey });
const contacts = await user.follows();
contacts.delete(new NDKUser({ pubkey: pubkey }));
const list = [...contacts].map((item) => [
'p',
item.pubkey,
item.relayUrls?.[0] || '',
'',
]);
const event = new NDKEvent(ndk);
event.content = '';
event.kind = NDKKind.Contacts;
event.tags = list;
await event.publish();
await ark.deleteContact({ pubkey });
} catch (e) {
toast.error(e);
}
};
useEffect(() => {
if (db.account.contacts.includes(pubkey)) {
if (ark.account.contacts.includes(pubkey)) {
setFollowed(true);
}
}, []);

View File

@@ -1,30 +1,60 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { UserProfile } from '@app/users/components/profile';
import { useNDK } from '@libs/ndk/provider';
import { useArk } from '@libs/ark';
import { MemoizedRepost, MemoizedTextNote, UnknownNote } from '@shared/notes';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import {
MemoizedRepost,
MemoizedTextNote,
NoteSkeleton,
UnknownNote,
} from '@shared/notes';
import { FETCH_LIMIT } from '@utils/constants';
export function UserScreen() {
const { pubkey } = useParams();
const { ndk } = useNDK();
const { status, data } = useQuery({
queryKey: ['user-feed', pubkey],
queryFn: async () => {
const events = await ndk.fetchEvents({
kinds: [NDKKind.Text, NDKKind.Repost],
authors: [pubkey],
limit: 20,
});
const sorted = [...events].sort((a, b) => b.created_at - a.created_at);
return sorted;
},
refetchOnWindowFocus: false,
});
const { ark } = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['user-posts', pubkey],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: [pubkey],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
refetchOnWindowFocus: false,
});
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data]
);
// render event match event kind
const renderItem = useCallback(
@@ -50,20 +80,33 @@ export function UserScreen() {
</h3>
<div className="mx-auto flex h-full max-w-[500px] flex-col justify-between gap-1.5 pb-4 pt-1.5">
{status === 'pending' ? (
<div>Loading...</div>
) : data.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-6 dark:bg-neutral-900">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm font-medium text-neutral-900 dark:text-neutral-100">
User doesn&apos;t have any posts in the last 48 hours.
</p>
</div>
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div>
) : (
data.map((item) => renderItem(item))
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</div>
</div>
</div>

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

@@ -0,0 +1,915 @@
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 { fetch } from '@tauri-apps/plugin-http';
import { Platform } from '@tauri-apps/plugin-os';
import Database from '@tauri-apps/plugin-sql';
import {
NostrEventExt,
NostrFetcher,
normalizeRelayUrl,
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, platform }: { storage: Database; platform: Platform }) {
this.#storage = storage;
this.platform = platform;
this.settings = {
autoupdate: false,
outbox: false,
media: true,
hashtag: true,
};
}
async #keyring_save(key: string, value: string) {
return await invoke('secure_save', { key, value });
}
async #keyring_load(key: string) {
try {
const value: string = await invoke('secure_load', { key });
if (!value) return null;
return value;
} catch {
return null;
}
}
async #keyring_remove(key: string) {
return await invoke('secure_remove', { key });
}
async #initNostrSigner({ nsecbunker }: { nsecbunker?: boolean }) {
const account = await this.getActiveAccount();
if (!account) return null;
// update active account
this.account = account;
try {
// NIP-46 Signer
if (nsecbunker) {
const localSignerPrivkey = await this.#keyring_load(
`${this.account.id}-nsecbunker`
);
if (!localSignerPrivkey) {
this.readyToSign = false;
return null;
}
const localSigner = new NDKPrivateKeySigner(localSignerPrivkey);
const bunker = new NDK({
explicitRelayUrls: ['wss://relay.nsecbunker.com', 'wss://nostr.vulpem.com'],
});
await bunker.connect();
const remoteSigner = new NDKNip46Signer(bunker, this.account.pubkey, localSigner);
await remoteSigner.blockUntilReady();
this.readyToSign = true;
return remoteSigner;
}
// Privkey Signer
const userPrivkey = await this.#keyring_load(this.account.pubkey);
if (!userPrivkey) {
this.readyToSign = false;
return null;
}
this.readyToSign = true;
return new NDKPrivateKeySigner(userPrivkey);
} catch (e) {
console.log(e);
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;
}
}
public 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.relays = [...ndk.pool.relays.values()].map((relay) => relay.url);
this.#ndk = ndk;
this.#fetcher = fetcher;
}
public updateNostrSigner({ signer }: { signer: NDKNip46Signer | NDKPrivateKeySigner }) {
this.#ndk.signer = signer;
this.readyToSign = true;
return this.#ndk.signer;
}
public async getAllCacheUsers() {
const results: Array<NDKCacheUser> = await this.#storage.select(
'SELECT * FROM ndk_users ORDER BY createdAt DESC;'
);
if (!results.length) return [];
const users: NDKCacheUserProfile[] = results.map((item) => ({
pubkey: item.pubkey,
...JSON.parse(item.profile as string),
}));
return users;
}
public async checkAccount() {
const result: Array<{ total: string }> = await this.#storage.select(
'SELECT COUNT(*) AS "total" FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
);
return parseInt(result[0].total);
}
public async getActiveAccount() {
const results: Array<Account> = await this.#storage.select(
'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
);
if (results.length) {
return results[0];
} else {
return null;
}
}
public async createAccount({
id,
pubkey,
privkey,
}: {
id: string;
pubkey: string;
privkey?: string;
}) {
const existAccounts: Array<Account> = await this.#storage.select(
'SELECT * FROM accounts WHERE pubkey = $1 ORDER BY id DESC LIMIT 1;',
[pubkey]
);
if (existAccounts.length) {
await this.#storage.execute(
"UPDATE accounts SET is_active = '1' WHERE pubkey = $1;",
[pubkey]
);
} else {
await this.#storage.execute(
'INSERT OR IGNORE INTO accounts (id, pubkey, is_active) VALUES ($1, $2, $3);',
[id, pubkey, 1]
);
if (privkey) await this.#keyring_save(pubkey, privkey);
}
const account = await this.getActiveAccount();
this.account = account;
this.account.contacts = [];
return account;
}
/**
* Save private key to OS secure storage
* @deprecated this method will be remove in the next update
*/
public async createPrivkey(name: string, privkey: string) {
return await this.#keyring_save(name, privkey);
}
/**
* Load private key from OS secure storage
* @deprecated this method will be remove in the next update
*/
public async loadPrivkey(name: string) {
return await this.#keyring_load(name);
}
/**
* Remove private key from OS secure storage
* @deprecated this method will be remove in the next update
*/
public async removePrivkey(name: string) {
return await this.#keyring_remove(name);
}
public async updateAccount(column: string, value: string) {
const insert = await this.#storage.execute(
`UPDATE accounts SET ${column} = $1 WHERE id = $2;`,
[value, this.account.id]
);
if (insert) {
const account = await this.getActiveAccount();
return account;
}
}
public async getWidgets() {
const widgets: Array<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,
rootReplyTo = undefined,
replyTo = undefined,
publish,
}: {
kind: NDKKind | number;
tags: NDKTag[];
content?: string;
rootReplyTo?: string;
replyTo?: string;
publish?: boolean;
}) {
try {
const event = new NDKEvent(this.#ndk);
if (content) event.content = content;
event.kind = kind;
event.tags = tags;
if (rootReplyTo) {
const rootEvent = await this.#ndk.fetchEvent(rootReplyTo);
if (rootEvent) event.tag(rootEvent, 'root');
}
if (replyTo) {
const replyEvent = await this.#ndk.fetchEvent(replyTo);
if (replyEvent) event.tag(replyEvent, 'reply');
}
if (publish) {
const publishedEvent = await event.publish();
if (!publishedEvent) throw new Error('Failed to publish event');
return publishedEvent.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
);
if (pubkey === this.account.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();
contacts.delete(new NDKUser({ pubkey: pubkey }));
const event = new NDKEvent(this.#ndk);
event.content = '';
event.kind = NDKKind.Contacts;
event.tags = [...contacts].map((item) => [
'p',
item.pubkey,
item.relayUrls?.[0] || '',
'',
]);
return await event.publish();
}
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 async getEventByFilter({ filter }: { filter: NDKFilter }) {
const event = await this.#ndk.fetchEvent(filter, {
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 getAllRelaysFromContacts() {
const LIMIT = 1;
const relayMap = new Map<string, string[]>();
const relayEvents = this.#fetcher.fetchLatestEventsPerAuthor(
{
authors: this.account.contacts,
relayUrls: this.relays,
},
{ kinds: [NDKKind.RelayList] },
LIMIT
);
for await (const { author, events } of relayEvents) {
if (events[0]) {
events[0].tags.forEach((tag) => {
const users = relayMap.get(tag[1]);
if (!users) return relayMap.set(tag[1], [author]);
return users.push(author);
});
}
}
return relayMap;
}
public async getInfiniteEvents({
filter,
limit,
pageParam = 0,
signal = undefined,
dedup = true,
}: {
filter: NDKFilter;
limit: number;
pageParam?: number;
signal?: AbortSignal;
dedup?: boolean;
}) {
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);
});
if (dedup) {
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);
}
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
}
public async getRelayEvents({
relayUrl,
filter,
limit,
pageParam = 0,
signal = undefined,
}: {
relayUrl: string;
filter: NDKFilter;
limit: number;
pageParam?: number;
signal?: AbortSignal;
dedup?: boolean;
}) {
const events = await this.#fetcher.fetchLatestEvents(
[normalizeRelayUrl(relayUrl)],
filter,
limit,
{
asOf: pageParam === 0 ? undefined : pageParam,
abortSignal: signal,
}
);
const ndkEvents = events.map((event) => {
return new NDKEvent(this.#ndk, event);
});
return ndkEvents.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) return false;
if (data.names[localPath.toLowerCase()] === pubkey) return true;
if (data.names[localPath] === pubkey) return true;
return false;
}
/**
* Return all NIP-04 messages
* @deprecated NIP-04 will be replace by NIP-44 in the next update
*/
public async getAllChats() {
const events = await this.#fetcher.fetchAllEvents(
this.relays,
{
kinds: [NDKKind.EncryptedDirectMessage],
'#p': [this.account.pubkey],
},
{ since: 0 }
);
const dedup: NDKEvent[] = Object.values(
events.reduce((ev, { id, content, pubkey, created_at, tags }) => {
if (ev[pubkey]) {
if (ev[pubkey].created_at < created_at) {
ev[pubkey] = { id, content, pubkey, created_at, tags };
}
} else {
ev[pubkey] = { id, content, pubkey, created_at, tags };
}
return ev;
}, {})
);
return dedup;
}
/**
* Return all NIP-04 messages by pubkey
* @deprecated NIP-04 will be replace by NIP-44 in the next update
*/
public async getAllMessagesByPubkey({ pubkey }: { pubkey: string }) {
let senderMessages: NostrEventExt<false>[] = [];
if (pubkey !== this.account.pubkey) {
senderMessages = await this.#fetcher.fetchAllEvents(
this.relays,
{
kinds: [NDKKind.EncryptedDirectMessage],
authors: [pubkey],
'#p': [this.account.pubkey],
},
{ since: 0 }
);
}
const userMessages = await this.#fetcher.fetchAllEvents(
this.relays,
{
kinds: [NDKKind.EncryptedDirectMessage],
authors: [this.account.pubkey],
'#p': [pubkey],
},
{ since: 0 }
);
const all = [...senderMessages, ...userMessages].sort(
(a, b) => a.created_at - b.created_at
);
return all as unknown as NDKEvent[];
}
public async nip04Decrypt({ event }: { event: NDKEvent }) {
try {
const sender = new NDKUser({
pubkey:
this.account.pubkey === event.pubkey
? event.tags.find((el) => el[0] === 'p')[1]
: event.pubkey,
});
const content = await this.#ndk.signer.decrypt(sender, event.content);
return content;
} catch (e) {
console.error(e);
}
}
public async nip04Encrypt({ content, pubkey }: { content: string; pubkey: string }) {
try {
const recipient = new NDKUser({ pubkey });
const message = await this.#ndk.signer.encrypt(recipient, content);
const event = new NDKEvent(this.#ndk);
event.content = message;
event.kind = NDKKind.EncryptedDirectMessage;
event.tag(recipient);
const publish = await event.publish();
if (!publish) throw new Error('Failed to send NIP-04 encrypted message');
return publish;
} catch (e) {
console.error(e);
}
}
public async replyTo({ content, event }: { content: string; event: NDKEvent }) {
try {
const replyEvent = new NDKEvent(this.#ndk);
replyEvent.content = content;
replyEvent.kind = NDKKind.Text;
replyEvent.tag(event, 'reply');
return await replyEvent.publish();
} catch (e) {
console.error(e);
}
}
}

View File

@@ -1,26 +1,30 @@
// inspired by: https://github.com/nostr-dev-kit/ndk/tree/master/ndk-cache-dexie
import { NDKEvent, NDKRelay, profileFromEvent } from '@nostr-dev-kit/ndk';
import type {
// 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,
NostrEvent,
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 { LumeStorage } from '@libs/storage/instance';
import { NDKCacheEvent, NDKCacheEventTag, NDKCacheUser } from '@utils/types';
export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
public db: LumeStorage;
public profiles?: LRUCache<Hexpubkey, NDKUserProfile>;
export class NDKCacheAdapterTauri implements NDKCacheAdapter {
#db: Database;
private dirtyProfiles: Set<Hexpubkey> = new Set();
public profiles?: LRUCache<Hexpubkey, NDKUserProfile>;
readonly locking: boolean;
constructor(db: LumeStorage) {
this.db = db;
constructor(db: Database) {
this.#db = db;
this.locking = true;
this.profiles = new LRUCache({
@@ -32,6 +36,115 @@ export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
}, 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))
@@ -44,7 +157,7 @@ export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
let profile = this.profiles.get(pubkey);
if (!profile) {
const user = await this.db.getCacheUser(pubkey);
const user = await this.#getCacheUser(pubkey);
if (user) {
profile = user.profile as NDKUserProfile;
this.profiles.set(pubkey, profile);
@@ -97,7 +210,7 @@ export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
if (event.isParamReplaceable()) {
const replaceableId = `${event.kind}:${event.pubkey}:${event.tagId()}`;
const existingEvent = await this.db.getCacheEvent(replaceableId);
const existingEvent = await this.#getCacheEvent(replaceableId);
if (
existingEvent &&
event.created_at &&
@@ -108,7 +221,7 @@ export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
}
if (addEvent) {
this.db.setCacheEvent({
this.#setCacheEvent({
id: event.tagId(),
pubkey: event.pubkey,
content: event.content,
@@ -124,7 +237,7 @@ export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
event.tags.forEach((tag) => {
if (tag[0].length !== 1) return;
this.db.setCacheEventTag({
this.#setCacheEventTag({
id: `${event.id}:${tag[0]}:${tag[1]}`,
eventId: event.id,
tag: tag[0],
@@ -153,7 +266,7 @@ export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
if (hasAllKeys && filter.authors) {
for (const pubkey of filter.authors) {
const events = await this.db.getCacheEventsByPubkey(pubkey);
const events = await this.#getCacheEventsByPubkey(pubkey);
for (const event of events) {
let rawEvent: NostrEvent;
try {
@@ -189,7 +302,7 @@ export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
if (hasAllKeys && filter.kinds) {
for (const kind of filter.kinds) {
const events = await this.db.getCacheEventsByKind(kind);
const events = await this.#getCacheEventsByKind(kind);
for (const event of events) {
let rawEvent: NostrEvent;
try {
@@ -223,7 +336,7 @@ export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
if (hasAllKeys && filter.ids) {
for (const id of filter.ids) {
const event = await this.db.getCacheEvent(id);
const event = await this.#getCacheEvent(id);
if (!event) continue;
let rawEvent: NostrEvent;
@@ -266,7 +379,7 @@ export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
for (const author of filter.authors) {
for (const dTag of filter['#d']) {
const replaceableId = `${kind}:${author}:${dTag}`;
const event = await this.db.getCacheEvent(replaceableId);
const event = await this.#getCacheEvent(replaceableId);
if (!event) continue;
let rawEvent: NostrEvent;
@@ -306,7 +419,7 @@ export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
if (filter.kinds && filter.authors) {
for (const kind of filter.kinds) {
for (const author of filter.authors) {
const events = await this.db.getCacheEventsByKindAndAuthor(kind, author);
const events = await this.#getCacheEventsByKindAndAuthor(kind, author);
for (const event of events) {
let rawEvent: NostrEvent;
@@ -371,12 +484,12 @@ export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
}
for (const value of values) {
const eventTags = await this.db.getCacheEventTagsByTagValue(tag + value);
const eventTags = await this.#getCacheEventTagsByTagValue(tag + value);
if (!eventTags.length) continue;
const eventIds = eventTags.map((t) => t.eventId);
const events = await this.db.getCacheEvents(eventIds);
const events = await this.#getCacheEvents(eventIds);
for (const event of events) {
let rawEvent;
try {
@@ -418,7 +531,7 @@ export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
}
if (profiles.length) {
await this.db.setCacheProfiles(profiles);
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';

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

@@ -0,0 +1,179 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useQueryClient } from '@tanstack/react-query';
import { ask } from '@tauri-apps/plugin-dialog';
import { platform } from '@tauri-apps/plugin-os';
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 { FETCH_LIMIT, 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);
const queryClient = useQueryClient();
async function initArk() {
try {
const sqlite = await Database.load('sqlite:lume_v2.db');
const platformName = await platform();
const _ark = new Ark({ storage: sqlite, platform: platformName });
await _ark.init();
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();
}
}
if (_ark.account) {
// prefetch newsfeed
await queryClient.prefetchInfiniteQuery({
queryKey: ['newsfeed'],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
return await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: !ark.account.contacts.length
? [ark.account.pubkey]
: ark.account.contacts,
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
},
});
// prefetch notification
await queryClient.prefetchInfiniteQuery({
queryKey: ['notification'],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
return await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
'#p': [ark.account.pubkey],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
},
});
}
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

@@ -1,214 +0,0 @@
import NDK, {
NDKEvent,
NDKKind,
NDKNip46Signer,
NDKPrivateKeySigner,
} from '@nostr-dev-kit/ndk';
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
import { useQueryClient } from '@tanstack/react-query';
import { ask } from '@tauri-apps/plugin-dialog';
import { relaunch } from '@tauri-apps/plugin-process';
import { NostrFetcher, normalizeRelayUrlSet } from 'nostr-fetch';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import NDKCacheAdapterTauri from '@libs/ndk/cache';
import { useStorage } from '@libs/storage/provider';
import { FETCH_LIMIT } from '@utils/constants';
export const NDKInstance = () => {
const { db } = useStorage();
const queryClient = useQueryClient();
const [ndk, setNDK] = useState<NDK | undefined>(undefined);
const [fetcher, setFetcher] = useState<NostrFetcher | undefined>(undefined);
const [relayUrls, setRelayUrls] = useState<string[]>([]);
async function getSigner(nsecbunker?: boolean) {
if (!db.account) return;
try {
// NIP-46 Signer
if (nsecbunker) {
const localSignerPrivkey = await db.secureLoad(`${db.account.id}-nsecbunker`);
if (!localSignerPrivkey) return null;
const localSigner = new NDKPrivateKeySigner(localSignerPrivkey);
const bunker = new NDK({
explicitRelayUrls: ['wss://relay.nsecbunker.com', 'wss://nostr.vulpem.com'],
});
await bunker.connect();
const remoteSigner = new NDKNip46Signer(bunker, db.account.pubkey, localSigner);
await remoteSigner.blockUntilReady();
return remoteSigner;
}
// Privkey Signer
const userPrivkey = await db.secureLoad(db.account.pubkey);
if (!userPrivkey) return null;
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 db.secureRemove(`${db.account.pubkey}-nsecbunker`);
await db.accountLogout();
}
return null;
}
}
async function initNDK() {
const outboxSetting = await db.getSettingValue('outbox');
const bunkerSetting = await db.getSettingValue('nsecbunker');
const bunker = !!parseInt(bunkerSetting);
const outbox = !!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']);
try {
const tauriAdapter = new NDKCacheAdapterTauri(db);
const instance = new NDK({
explicitRelayUrls,
outboxRelayUrls,
blacklistRelayUrls,
enableOutboxModel: outbox,
autoConnectUserRelays: true,
autoFetchUserMutelist: true,
cacheAdapter: tauriAdapter,
// clientName: 'Lume',
// clientNip89: '',
});
// add signer if exist
const signer = await getSigner(bunker);
if (signer) instance.signer = signer;
// connect
await instance.connect();
const _fetcher = NostrFetcher.withCustomPool(ndkAdapter(instance));
// update account's metadata
if (db.account) {
const user = instance.getUser({ pubkey: db.account.pubkey });
instance.activeUser = user;
const contacts = await user.follows(undefined /* outbox */);
db.account.contacts = [...contacts].map((user) => user.pubkey);
// prefetch newsfeed
await queryClient.prefetchInfiniteQuery({
queryKey: ['newsfeed'],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const rootIds = new Set();
const dedupQueue = new Set();
const events = await _fetcher.fetchLatestEvents(
explicitRelayUrls,
{
kinds: [NDKKind.Text, NDKKind.Repost],
authors: db.account.contacts,
},
FETCH_LIMIT,
{ asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal }
);
const ndkEvents = events.map((event) => {
return new NDKEvent(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);
},
});
// prefetch notification
await queryClient.prefetchInfiniteQuery({
queryKey: ['notification'],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await _fetcher.fetchLatestEvents(
explicitRelayUrls,
{
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
'#p': [db.account.pubkey],
},
FETCH_LIMIT,
{ asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal }
);
const ndkEvents = events.map((event) => {
return new NDKEvent(ndk, event);
});
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
},
});
}
setNDK(instance);
setFetcher(_fetcher);
setRelayUrls(explicitRelayUrls);
} catch (e) {
console.error(e);
const yes = await ask(e, {
title: 'Lume',
type: 'error',
okLabel: 'Yes',
});
if (yes) relaunch();
}
}
useEffect(() => {
if (!ndk) initNDK();
}, []);
return {
ndk,
fetcher,
relayUrls,
};
};

View File

@@ -1,80 +0,0 @@
// source: https://github.com/nostr-dev-kit/ndk-react/
import NDK from '@nostr-dev-kit/ndk';
import Markdown from 'markdown-to-jsx';
import { NostrFetcher } from 'nostr-fetch';
import { PropsWithChildren, createContext, useContext } from 'react';
import { NDKInstance } from '@libs/ndk/instance';
import { LoaderIcon } from '@shared/icons';
import { QUOTES } from '@utils/constants';
interface NDKContext {
ndk: undefined | NDK;
fetcher: undefined | NostrFetcher;
relayUrls: string[];
}
const NDKContext = createContext<NDKContext>({
ndk: undefined,
fetcher: undefined,
relayUrls: [],
});
const NDKProvider = ({ children }: PropsWithChildren<object>) => {
const { ndk, relayUrls, fetcher } = NDKInstance();
if (!ndk)
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">Connecting to relays...</p>
</div>
</div>
);
return (
<NDKContext.Provider
value={{
ndk,
relayUrls,
fetcher,
}}
>
{children}
</NDKContext.Provider>
);
};
const useNDK = () => {
const context = useContext(NDKContext);
if (context === undefined) {
throw new Error('import NDKProvider to use useNDK');
}
return context;
};
export { NDKProvider, useNDK };

View File

@@ -1,486 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { invoke } from '@tauri-apps/api/primitives';
import { Platform } from '@tauri-apps/plugin-os';
import Database from '@tauri-apps/plugin-sql';
import { rawEvent } from '@utils/transform';
import type {
Account,
DBEvent,
NDKCacheEvent,
NDKCacheEventTag,
NDKCacheUser,
NDKCacheUserProfile,
Relays,
Widget,
} from '@utils/types';
export class LumeStorage {
public db: Database;
public account: Account | null;
public platform: Platform | null;
public settings: {
autoupdate: boolean;
outbox: boolean;
media: boolean;
hashtag: boolean;
};
constructor(sqlite: Database, platform: Platform) {
this.db = sqlite;
this.account = null;
this.platform = platform;
this.settings = { autoupdate: false, outbox: false, media: true, hashtag: true };
}
public async secureSave(key: string, value: string) {
return await invoke('secure_save', { key, value });
}
public async secureLoad(key: string) {
try {
const value: string = await invoke('secure_load', { key });
if (!value) return null;
return value;
} catch {
return null;
}
}
public async secureRemove(key: string) {
return await invoke('secure_remove', { key });
}
public async getAllCacheUsers() {
const results: Array<NDKCacheUser> = await this.db.select(
'SELECT * FROM ndk_users ORDER BY createdAt DESC;'
);
if (!results.length) return [];
const users: NDKCacheUserProfile[] = results.map((item) => ({
pubkey: item.pubkey,
...JSON.parse(item.profile as string),
}));
return users;
}
public async getCacheUser(pubkey: string) {
const results: Array<NDKCacheUser> = await this.db.select(
'SELECT * FROM ndk_users WHERE pubkey = $1 ORDER BY pubkey DESC LIMIT 1;',
[pubkey]
);
if (!results.length) return null;
if (typeof results[0].profile === 'string')
results[0].profile = JSON.parse(results[0].profile);
return results[0];
}
public async getCacheEvent(id: string) {
const results: Array<NDKCacheEvent> = await this.db.select(
'SELECT * FROM ndk_events WHERE id = $1 ORDER BY id DESC LIMIT 1;',
[id]
);
if (!results.length) return null;
return results[0];
}
public async getCacheEvents(ids: string[]) {
const idsArr = `'${ids.join("','")}'`;
const results: Array<NDKCacheEvent> = await this.db.select(
`SELECT * FROM ndk_events WHERE id IN (${idsArr}) ORDER BY id;`
);
if (!results.length) return [];
return results;
}
public async getCacheEventsByPubkey(pubkey: string) {
const results: Array<NDKCacheEvent> = await this.db.select(
'SELECT * FROM ndk_events WHERE pubkey = $1 ORDER BY id;',
[pubkey]
);
if (!results.length) return [];
return results;
}
public async getCacheEventsByKind(kind: number) {
const results: Array<NDKCacheEvent> = await this.db.select(
'SELECT * FROM ndk_events WHERE kind = $1 ORDER BY id;',
[kind]
);
if (!results.length) return [];
return results;
}
public async getCacheEventsByKindAndAuthor(kind: number, pubkey: string) {
const results: Array<NDKCacheEvent> = await this.db.select(
'SELECT * FROM ndk_events WHERE kind = $1 AND pubkey = $2 ORDER BY id;',
[kind, pubkey]
);
if (!results.length) return [];
return results;
}
public async getCacheEventTagsByTagValue(tagValue: string) {
const results: Array<NDKCacheEventTag> = await this.db.select(
'SELECT * FROM ndk_eventtags WHERE tagValue = $1 ORDER BY id;',
[tagValue]
);
if (!results.length) return [];
return results;
}
public async setCacheEvent({
id,
pubkey,
content,
kind,
createdAt,
relay,
event,
}: NDKCacheEvent) {
return await this.db.execute(
'INSERT OR IGNORE INTO ndk_events (id, pubkey, content, kind, createdAt, relay, event) VALUES ($1, $2, $3, $4, $5, $6, $7);',
[id, pubkey, content, kind, createdAt, relay, event]
);
}
public async setCacheEventTag({ id, eventId, tag, value, tagValue }: NDKCacheEventTag) {
return await this.db.execute(
'INSERT OR IGNORE INTO ndk_eventtags (id, eventId, tag, value, tagValue) VALUES ($1, $2, $3, $4, $5);',
[id, eventId, tag, value, tagValue]
);
}
public async setCacheProfiles(profiles: Array<NDKCacheUser>) {
return await Promise.all(
profiles.map(
async (profile) =>
await this.db.execute(
'INSERT OR IGNORE INTO ndk_users (pubkey, profile, createdAt) VALUES ($1, $2, $3);',
[profile.pubkey, profile.profile, profile.createdAt]
)
)
);
}
public async checkAccount() {
const result: Array<{ total: string }> = await this.db.select(
'SELECT COUNT(*) AS "total" FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
);
return parseInt(result[0].total);
}
public async getActiveAccount() {
const results: Array<Account> = await this.db.select(
'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
);
if (results.length) {
this.account = results[0];
this.account.contacts = [];
} else {
console.log('no active account, please create new account');
return null;
}
}
public async createAccount(npub: string, pubkey: string) {
const existAccounts: Array<Account> = await this.db.select(
'SELECT * FROM accounts WHERE pubkey = $1 ORDER BY id DESC LIMIT 1;',
[pubkey]
);
if (existAccounts.length) {
await this.db.execute("UPDATE accounts SET is_active = '1' WHERE pubkey = $1;", [
pubkey,
]);
} else {
await this.db.execute(
'INSERT OR IGNORE INTO accounts (id, pubkey, is_active) VALUES ($1, $2, $3);',
[npub, pubkey, 1]
);
}
return await this.getActiveAccount();
}
public async updateAccount(column: string, value: string) {
const insert = await this.db.execute(
`UPDATE accounts SET ${column} = $1 WHERE id = $2;`,
[value, this.account.id]
);
if (insert) {
const account = await this.getActiveAccount();
return account;
}
}
public async getWidgets() {
const widgets: Array<Widget> = await this.db.select(
'SELECT * FROM widgets WHERE account_id = $1 ORDER BY created_at DESC;',
[this.account.id]
);
return widgets;
}
public async createWidget(kind: number, title: string, content: string | string[]) {
const insert = await this.db.execute(
'INSERT INTO widgets (account_id, kind, title, content) VALUES ($1, $2, $3, $4);',
[this.account.id, kind, title, content]
);
if (insert) {
const widgets: Array<Widget> = await this.db.select(
'SELECT * FROM widgets ORDER BY id DESC LIMIT 1;'
);
if (widgets.length < 1) console.error('get created widget failed');
return widgets[0];
} else {
console.error('create widget failed');
}
}
public async removeWidget(id: string) {
const res = await this.db.execute('DELETE FROM widgets WHERE id = $1;', [id]);
if (res) return id;
}
public async createEvent(event: NDKEvent) {
const rawNostrEvent = rawEvent(event);
let root: string;
let reply: string;
if (event.tags?.[0]?.[0] === 'e' && !event.tags?.[0]?.[3]) {
root = event.tags[0][1];
} else {
root = event.tags.find((el) => el[3] === 'root')?.[1];
reply = event.tags.find((el) => el[3] === 'reply')?.[1];
}
return await this.db.execute(
'INSERT OR IGNORE INTO events (id, account_id, event, author, kind, root_id, reply_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8);',
[
event.id,
this.account.id,
JSON.stringify(rawNostrEvent),
event.pubkey,
event.kind,
root,
reply,
event.created_at,
]
);
}
public async getEventByID(id: string) {
const results: DBEvent[] = await this.db.select(
'SELECT * FROM events WHERE id = $1 LIMIT 1;',
[id]
);
if (results.length < 1) return null;
return JSON.parse(results[0].event as string) as NDKEvent;
}
public async countTotalEvents() {
const result: Array<{ total: string }> = await this.db.select(
'SELECT COUNT(*) AS "total" FROM events WHERE account_id = $1;',
[this.account.id]
);
return parseInt(result[0].total);
}
public async getAllEvents(limit: number, offset: number) {
const totalEvents = await this.countTotalEvents();
const nextCursor = offset + limit;
const events: { data: DBEvent[] | null; nextCursor: number } = {
data: null,
nextCursor: 0,
};
const query: DBEvent[] = await this.db.select(
'SELECT * FROM events WHERE account_id = $1 GROUP BY root_id ORDER BY created_at DESC LIMIT $2 OFFSET $3;',
[this.account.id, limit, offset]
);
if (query && query.length > 0) {
events['data'] = query;
events['nextCursor'] =
Math.round(totalEvents / nextCursor) > 1 ? nextCursor : undefined;
return events;
}
return {
data: [],
nextCursor: 0,
};
}
public async getAllEventsByAuthors(authors: string[], limit: number, offset: number) {
const totalEvents = await this.countTotalEvents();
const nextCursor = offset + limit;
const authorsArr = `'${authors.join("','")}'`;
const events: { data: DBEvent[] | null; nextCursor: number } = {
data: null,
nextCursor: 0,
};
const query: DBEvent[] = await this.db.select(
`SELECT * FROM events WHERE author IN (${authorsArr}) ORDER BY created_at DESC LIMIT $1 OFFSET $2;`,
[limit, offset]
);
if (query && query.length > 0) {
events['data'] = query;
events['nextCursor'] =
Math.round(totalEvents / nextCursor) > 1 ? nextCursor : undefined;
return events;
}
return {
data: [],
nextCursor: 0,
};
}
public async getAllEventsByKinds(kinds: number[], limit: number, offset: number) {
const totalEvents = await this.countTotalEvents();
const nextCursor = offset + limit;
const authorsArr = `'${kinds.join("','")}'`;
const events: { data: DBEvent[] | null; nextCursor: number } = {
data: null,
nextCursor: 0,
};
const query: DBEvent[] = await this.db.select(
`SELECT * FROM events WHERE kinds IN (${authorsArr}) AND account_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3;`,
[this.account.id, limit, offset]
);
if (query && query.length > 0) {
events['data'] = query;
events['nextCursor'] =
Math.round(totalEvents / nextCursor) > 1 ? nextCursor : undefined;
return events;
}
return {
data: [],
nextCursor: 0,
};
}
public async isEventsEmpty() {
const results: DBEvent[] = await this.db.select(
'SELECT * FROM events WHERE account_id = $1 ORDER BY id DESC LIMIT 1;',
[this.account.id]
);
return results.length < 1;
}
public async createRelay(relay: string, purpose?: string) {
const existRelays: Relays[] = await this.db.select(
'SELECT * FROM relays WHERE relay = $1 AND account_id = $2 ORDER BY id DESC LIMIT 1;',
[relay, this.account.id]
);
if (existRelays.length) return;
return await this.db.execute(
'INSERT OR IGNORE INTO relays (account_id, relay, purpose) VALUES ($1, $2, $3);',
[this.account.id, relay, purpose || '']
);
}
public async removeRelay(relay: string) {
return await this.db.execute(`DELETE FROM relays WHERE relay = "${relay}";`);
}
public async createSetting(key: string, value: string | undefined) {
if (value) {
return await this.db.execute(
'INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2);',
[key, value]
);
}
const currentSetting = await this.checkSettingValue(key);
if (!currentSetting)
return await this.db.execute(
'INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2);',
[key, value]
);
const currentValue = !!parseInt(currentSetting);
return await this.db.execute('UPDATE settings SET value = $1 WHERE key = $2;', [
+!currentValue,
key,
]);
}
public async getAllSettings() {
const results: { key: string; value: string }[] = await this.db.select(
'SELECT * FROM settings ORDER BY id DESC;'
);
if (results.length < 1) return null;
return results;
}
public async checkSettingValue(key: string) {
const results: { key: string; value: string }[] = await this.db.select(
'SELECT * FROM settings WHERE key = $1 ORDER BY id DESC LIMIT 1;',
[key]
);
if (!results.length) return false;
return results[0].value;
}
public async getSettingValue(key: string) {
const results: { key: string; value: string }[] = await this.db.select(
'SELECT * FROM settings WHERE key = $1 ORDER BY id DESC LIMIT 1;',
[key]
);
if (!results.length) return '0';
return results[0].value;
}
public async clearCache() {
await this.db.execute('DELETE FROM ndk_events;');
await this.db.execute('DELETE FROM ndk_eventtags;');
await this.db.execute('DELETE FROM ndk_users;');
}
public async accountLogout() {
// update current account status
await this.db.execute("UPDATE accounts SET is_active = '0' WHERE id = $1;", [
this.account.id,
]);
this.account = null;
}
public async close() {
return this.db.close();
}
}

View File

@@ -1,128 +0,0 @@
import { message } from '@tauri-apps/plugin-dialog';
import { platform } from '@tauri-apps/plugin-os';
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 { LumeStorage } from '@libs/storage/instance';
import { LoaderIcon } from '@shared/icons';
import { QUOTES } from '@utils/constants';
interface StorageContext {
db: LumeStorage;
}
const StorageContext = createContext<StorageContext>({
db: undefined,
});
const StorageInstance = () => {
const [db, setDB] = useState<LumeStorage>(undefined);
const [isNewVersion, setIsNewVersion] = useState(false);
const initLumeStorage = async () => {
try {
const sqlite = await Database.load('sqlite:lume_v2.db');
const platformName = await platform();
const lumeStorage = new LumeStorage(sqlite, platformName);
if (!lumeStorage.account) await lumeStorage.getActiveAccount();
const settings = await lumeStorage.getAllSettings();
let autoUpdater = false;
if (settings) {
settings.forEach((item) => {
if (item.key === 'outbox') lumeStorage.settings.outbox = !!parseInt(item.value);
if (item.key === 'media') lumeStorage.settings.media = !!parseInt(item.value);
if (item.key === 'hashtag')
lumeStorage.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();
}
}
setDB(lumeStorage);
} catch (e) {
await message(`Cannot initialize database: ${e}`, {
title: 'Lume',
type: 'error',
});
}
};
useEffect(() => {
if (!db) initLumeStorage();
}, []);
return { db, isNewVersion };
};
const StorageProvider = ({ children }: PropsWithChildren<object>) => {
const { db, isNewVersion } = StorageInstance();
if (!db)
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 <StorageContext.Provider value={{ db }}>{children}</StorageContext.Provider>;
};
const useStorage = () => {
const context = useContext(StorageContext);
if (context === undefined) {
throw new Error('Storage not found');
}
return context;
};
export { StorageProvider, useStorage };

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

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

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.id}-nsecbunker`);
// logout
await db.accountLogout();
await ark.logout();
// clear cache
queryClient.clear();

View File

@@ -1,16 +1,16 @@
import { message } from '@tauri-apps/plugin-dialog';
import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon } from '@shared/icons';
import { useArk } from '@libs/ark';
import { useNostr } from '@utils/hooks/useNostr';
import { LoaderIcon } from '@shared/icons';
export function AvatarUploader({
setPicture,
}: {
setPicture: Dispatch<SetStateAction<string>>;
}) {
const { upload } = useNostr();
const { ark } = useArk();
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {
@@ -18,7 +18,7 @@ export function AvatarUploader({
// start loading
setLoading(true);
const image = await upload();
const image = await ark.upload({});
if (image) {
setPicture(image);

View File

@@ -1,16 +1,16 @@
import { message } from '@tauri-apps/plugin-dialog';
import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useArk } from '@libs/ark';
import { useNostr } from '@utils/hooks/useNostr';
import { LoaderIcon, PlusIcon } from '@shared/icons';
export function BannerUploader({
setBanner,
}: {
setBanner: Dispatch<SetStateAction<string>>;
}) {
const { upload } = useNostr();
const { ark } = useArk();
const [loading, setLoading] = useState(false);
const uploadBanner = async () => {
@@ -18,7 +18,7 @@ export function BannerUploader({
// start loading
setLoading(true);
const image = await upload();
const image = await ark.upload({});
if (image) {
setBanner(image);

View File

@@ -2,21 +2,21 @@ import { Outlet, ScrollRestoration } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { WindowTitlebar } from 'tauri-controls';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import { Navigation } from '@shared/navigation';
export function AppLayout() {
const { db } = useStorage();
const { ark } = useArk();
return (
<div
className={twMerge(
'flex h-screen w-screen flex-col',
db.platform !== 'macos' ? 'bg-neutral-50 dark:bg-neutral-950' : ''
ark.platform !== 'macos' ? 'bg-neutral-50 dark:bg-neutral-950' : ''
)}
>
{db.platform !== 'macos' ? (
{ark.platform !== 'macos' ? (
<WindowTitlebar />
) : (
<div data-tauri-drag-region className="h-9" />
@@ -26,7 +26,7 @@ export function AppLayout() {
data-tauri-drag-region
className={twMerge(
'h-full w-[64px] shrink-0',
db.platform !== 'macos' ? 'pt-2' : 'pt-0'
ark.platform !== 'macos' ? 'pt-2' : 'pt-0'
)}
>
<Navigation />

View File

@@ -1,14 +1,14 @@
import { Outlet, ScrollRestoration } from 'react-router-dom';
import { WindowTitlebar } from 'tauri-controls';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
export function AuthLayout() {
const { db } = useStorage();
const { ark } = useArk();
return (
<div className="flex h-screen w-screen flex-col">
{db.platform !== 'macos' ? (
{ark.platform !== 'macos' ? (
<WindowTitlebar />
) : (
<div data-tauri-drag-region className="h-9" />

View File

@@ -2,17 +2,17 @@ import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { WindowTitlebar } from 'tauri-controls';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import { ArrowLeftIcon } from '@shared/icons';
export function NewLayout() {
const { db } = useStorage();
const { ark } = useArk();
const location = useLocation();
return (
<div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
{db.platform !== 'macos' ? (
{ark.platform !== 'macos' ? (
<WindowTitlebar />
) : (
<div data-tauri-drag-region className="h-9 shrink-0" />

View File

@@ -1,14 +1,14 @@
import { Outlet, ScrollRestoration } from 'react-router-dom';
import { WindowTitlebar } from 'tauri-controls';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
export function NoteLayout() {
const { db } = useStorage();
const { ark } = useArk();
return (
<div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
{db.platform !== 'macos' ? (
{ark.platform !== 'macos' ? (
<WindowTitlebar />
) : (
<div data-tauri-drag-region className="h-9" />

View File

@@ -2,7 +2,7 @@ import { NavLink, Outlet, ScrollRestoration, useNavigate } from 'react-router-do
import { twMerge } from 'tailwind-merge';
import { WindowTitlebar } from 'tauri-controls';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import {
AdvancedSettingsIcon,
@@ -14,12 +14,12 @@ import {
} from '@shared/icons';
export function SettingsLayout() {
const { db } = useStorage();
const { ark } = useArk();
const navigate = useNavigate();
return (
<div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
{db.platform !== 'macos' ? (
{ark.platform !== 'macos' ? (
<WindowTitlebar />
) : (
<div data-tauri-drag-region className="h-9" />

View File

@@ -4,7 +4,7 @@ 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 { ReactionIcon } from '@shared/icons';
@@ -35,7 +35,7 @@ export function NoteReaction({ event }: { event: NDKEvent }) {
const [open, setOpen] = useState(false);
const [reaction, setReaction] = useState<string | null>(null);
const { ndk } = useNDK();
const { ark } = useArk();
const navigate = useNavigate();
const getReactionImage = (content: string) => {
@@ -45,7 +45,7 @@ export function NoteReaction({ event }: { event: NDKEvent }) {
const react = async (content: string) => {
try {
if (!ndk.signer) return navigate('/new/privkey');
if (!ark.readyToSign) return navigate('/new/privkey');
setReaction(content);

View File

@@ -6,7 +6,7 @@ import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge';
import { useNDK } from '@libs/ndk/provider';
import { useArk } from '@libs/ark';
import { LoaderIcon, RepostIcon } from '@shared/icons';
@@ -15,12 +15,12 @@ export function NoteRepost({ event }: { event: NDKEvent }) {
const [isLoading, setIsLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const { ndk } = useNDK();
const { ark } = useArk();
const navigate = useNavigate();
const submit = async () => {
try {
if (!ndk.signer) return navigate('/new/privkey');
if (!ark.readyToSign) return navigate('/new/privkey');
setIsLoading(true);

View File

@@ -9,8 +9,7 @@ import { useEffect, useRef, useState } from 'react';
import CurrencyInput from 'react-currency-input-field';
import { useNavigate } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import { CancelIcon, ZapIcon } from '@shared/icons';
@@ -20,11 +19,7 @@ import { compactNumber } from '@utils/number';
import { displayNpub } from '@utils/shortenKey';
export function NoteZap({ event }: { event: NDKEvent }) {
const nwc = useRef(null);
const navigate = useNavigate();
const { db } = useStorage();
const { ndk } = useNDK();
const { ark } = useArk();
const { user } = useProfile(event.pubkey);
const [walletConnectURL, setWalletConnectURL] = useState<string>(null);
@@ -35,9 +30,12 @@ export function NoteZap({ event }: { event: NDKEvent }) {
const [isCompleted, setIsCompleted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const nwc = useRef(null);
const navigate = useNavigate();
const createZapRequest = async () => {
try {
if (!ndk.signer) return navigate('/new/privkey');
if (!ark.readyToSign) return navigate('/new/privkey');
const zapAmount = parseInt(amount) * 1000;
const res = await event.zap(zapAmount, zapMessage);
@@ -88,7 +86,7 @@ export function NoteZap({ event }: { event: NDKEvent }) {
useEffect(() => {
async function getWalletConnectURL() {
const uri: string = await invoke('secure_load', {
key: `${db.account.pubkey}-nwc`,
key: `${ark.account.pubkey}-nwc`,
});
if (uri) setWalletConnectURL(uri);
}

View File

@@ -1,20 +1,21 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { memo } from 'react';
import { useArk } from '@libs/ark';
import { ReplyIcon, RepostIcon } from '@shared/icons';
import { ChildNote, TextKind } from '@shared/notes';
import { User } from '@shared/user';
import { WIDGET_KIND } from '@utils/constants';
import { formatCreatedAt } from '@utils/createdAt';
import { useNostr } from '@utils/hooks/useNostr';
import { useWidget } from '@utils/hooks/useWidget';
export function NotifyNote({ event }: { event: NDKEvent }) {
const { getEventThread } = useNostr();
const { ark } = useArk();
const { addWidget } = useWidget();
const thread = getEventThread(event.tags);
const thread = ark.getEventThread({ tags: event.tags });
const createdAt = formatCreatedAt(event.created_at, false);
if (event.kind === NDKKind.Reaction) {

View File

@@ -1,15 +1,15 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { NDKEvent } from '@nostr-dev-kit/ndk';
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';
import { ReplyMediaUploader } from '@shared/notes';
export function NoteReplyForm({ rootEvent }: { rootEvent: NDKEvent }) {
const { ndk } = useNDK();
const { ark } = useArk();
const navigate = useNavigate();
const [value, setValue] = useState('');
@@ -17,22 +17,14 @@ export function NoteReplyForm({ rootEvent }: { rootEvent: NDKEvent }) {
const submit = async () => {
try {
if (!ndk.signer) return navigate('/new/privkey');
if (!ark.readyToSign) return navigate('/new/privkey');
setLoading(true);
const event = new NDKEvent(ndk);
event.content = value;
event.kind = NDKKind.Text;
// tag root event
event.tag(rootEvent, 'reply');
// publish event
const publishedRelays = await event.publish();
const publish = await ark.replyTo({ content: value, event: rootEvent });
if (publishedRelays) {
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
if (publish) {
toast.success(`Broadcasted to ${publish.size} relays successfully.`);
// reset state
setValue('');

View File

@@ -8,7 +8,13 @@ import { User } from '@shared/user';
import { NDKEventWithReplies } from '@utils/types';
export function Reply({ event }: { event: NDKEventWithReplies }) {
export function Reply({
event,
rootEvent,
}: {
event: NDKEventWithReplies;
rootEvent: string;
}) {
const [open, setOpen] = useState(false);
return (
@@ -30,7 +36,7 @@ export function Reply({ event }: { event: NDKEventWithReplies }) {
</div>
</Collapsible.Trigger>
) : null}
<NoteActions event={event} canOpenEvent={false} />
<NoteActions event={event} rootEventId={rootEvent} canOpenEvent={false} />
</div>
</div>
<div className={twMerge('px-3', open ? 'pb-3' : '')}>

View File

@@ -1,38 +1,42 @@
import { NDKSubscription } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { Reply } from '@shared/notes';
import { useNostr } from '@utils/hooks/useNostr';
import { NDKEventWithReplies } from '@utils/types';
export function ReplyList({ eventId }: { eventId: string }) {
const { fetchAllReplies, sub } = useNostr();
const { ark } = useArk();
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
useEffect(() => {
let sub: NDKSubscription;
let isCancelled = false;
async function fetchRepliesAndSub() {
const events = await fetchAllReplies(eventId);
const events = await ark.getThreads({ id: eventId });
if (!isCancelled) {
setData(events);
}
// subscribe for new replies
sub(
{
sub = ark.subscribe({
filter: {
'#e': [eventId],
since: Math.floor(Date.now() / 1000),
},
(event: NDKEventWithReplies) => setData((prev) => [event, ...prev]),
false
);
closeOnEose: false,
cb: (event: NDKEventWithReplies) => setData((prev) => [event, ...prev]),
});
}
fetchRepliesAndSub();
return () => {
isCancelled = true;
if (sub) sub.stop();
};
}, [eventId]);
@@ -59,7 +63,7 @@ export function ReplyList({ eventId }: { eventId: string }) {
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} root={eventId} />)
data.map((event) => <Reply key={event.id} event={event} rootEvent={eventId} />)
)}
</div>
);

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,22 @@ import {
import { User } from '@shared/user';
export function Repost({ event }: { event: NDKEvent }) {
const { ndk } = useNDK();
const { status, data: repostEvent } = useQuery({
const { ark } = useArk();
const {
isLoading,
isError,
data: repostEvent,
} = useQuery({
queryKey: ['repost', event.id],
queryFn: async () => {
try {
if (event.content.length > 50) {
const embed = JSON.parse(event.content) as NostrEvent;
const embedEvent = new NDKEvent(ndk, embed);
return embedEvent;
return 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'));
return ndkEvent;
return await ark.getEventById({ id });
} catch {
throw new Error('Failed to get repost event');
}
@@ -53,7 +51,7 @@ export function Repost({ event }: { event: NDKEvent }) {
}
};
if (status === 'pending') {
if (isLoading) {
return (
<div className="w-full px-3 pb-3">
<NoteSkeleton />
@@ -61,6 +59,21 @@ export function Repost({ event }: { event: NDKEvent }) {
);
}
if (isError) {
return (
<div className="mb-3 h-min w-full px-3">
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
<User pubkey={event.pubkey} time={event.created_at} variant="repost" />
<div className="relative flex flex-col gap-2">
<div className="px-3">
<p>Failed to load event</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="mb-3 h-min w-full px-3">
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">

View File

@@ -2,20 +2,21 @@ import { NDKEvent } from '@nostr-dev-kit/ndk';
import { memo } from 'react';
import { twMerge } from 'tailwind-merge';
import { useArk } from '@libs/ark';
import { ChildNote, NoteActions } from '@shared/notes';
import { User } from '@shared/user';
import { WIDGET_KIND } from '@utils/constants';
import { useNostr } from '@utils/hooks/useNostr';
import { useRichContent } from '@utils/hooks/useRichContent';
import { useWidget } from '@utils/hooks/useWidget';
export function TextNote({ event, className }: { event: NDKEvent; className?: string }) {
const { parsedContent } = useRichContent(event.content);
const { addWidget } = useWidget();
const { getEventThread } = useNostr();
const { ark } = useArk();
const thread = getEventThread(event.tags);
const thread = ark.getEventThread({ tags: event.tags });
return (
<div className={twMerge('mb-3 h-min w-full px-3', className)}>

View File

@@ -1,4 +1,4 @@
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import { CancelIcon } from '@shared/icons';
import { User } from '@shared/user';
@@ -14,7 +14,7 @@ export function TitleBar({
title?: string;
isLive?: boolean;
}) {
const { db } = useStorage();
const { ark } = useArk();
const { removeWidget } = useWidget();
return (
@@ -33,13 +33,13 @@ export function TitleBar({
<div className="col-span-1 flex justify-center">
{id === '9999' ? (
<div className="isolate flex -space-x-2">
{db.account.contacts
{ark.account.contacts
?.slice(0, 8)
.map((item) => <User key={item} pubkey={item} variant="ministacked" />)}
{db.account.contacts?.length > 8 ? (
{ark.account.contacts?.length > 8 ? (
<div className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-300 text-neutral-900 ring-1 ring-white dark:bg-neutral-700 dark:text-neutral-100 dark:ring-black">
<span className="text-[8px] font-medium">
+{db.account.contacts?.length - 8}
+{ark.account.contacts?.length - 8}
</span>
</div>
) : null}

View File

@@ -1,10 +1,8 @@
import { NDKEvent, NDKKind, NDKUser } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link } 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 { NIP05 } from '@shared/nip05';
@@ -12,18 +10,14 @@ import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function UserProfile({ pubkey }: { pubkey: string }) {
const { db } = useStorage();
const { ndk } = useNDK();
const { ark } = useArk();
const { user } = useProfile(pubkey);
const [followed, setFollowed] = useState(false);
const navigate = useNavigate();
const follow = async (pubkey: string) => {
try {
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 = await ark.createContact({ pubkey });
if (add) {
setFollowed(true);
@@ -37,22 +31,9 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
const unfollow = async (pubkey: string) => {
try {
if (!ndk.signer) return navigate('/new/privkey');
const remove = await ark.deleteContact({ pubkey });
const user = ndk.getUser({ pubkey: db.account.pubkey });
const contacts = await user.follows();
contacts.delete(new NDKUser({ pubkey: pubkey }));
let list: string[][];
contacts.forEach((el) => list.push(['p', el.pubkey, el.relayUrls?.[0] || '', '']));
const event = new NDKEvent(ndk);
event.content = '';
event.kind = NDKKind.Contacts;
event.tags = list;
const publishedRelays = await event.publish();
if (publishedRelays) {
if (remove) {
setFollowed(false);
}
} catch (error) {
@@ -61,7 +42,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
};
useEffect(() => {
if (db.account.contacts.includes(pubkey)) {
if (ark.account.contacts.includes(pubkey)) {
setFollowed(true);
}
}, []);

View File

@@ -1,11 +1,10 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { FetchFilter } from 'nostr-fetch';
import { useMemo } from 'react';
import { VList } from 'virtua';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { MemoizedArticleNote } from '@shared/notes';
@@ -16,8 +15,7 @@ import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
export function ArticleWidget({ widget }: { widget: Widget }) {
const { db } = useStorage();
const { ndk, relayUrls, fetcher } = useNDK();
const { ark } = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['article', widget.id],
@@ -39,20 +37,19 @@ export function ArticleWidget({ widget }: { widget: Widget }) {
} else {
filter = {
kinds: [NDKKind.Article],
authors: db.account.contacts,
authors: ark.account.contacts,
};
}
const events = await fetcher.fetchLatestEvents(relayUrls, filter, FETCH_LIMIT, {
asOf: pageParam === 0 ? undefined : pageParam,
abortSignal: signal,
const events = await ark.getInfiniteEvents({
filter,
limit: FETCH_LIMIT,
pageParam,
signal,
dedup: false,
});
const ndkEvents = events.map((event) => {
return new NDKEvent(ndk, event);
});
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);

View File

@@ -1,11 +1,9 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { FetchFilter } from 'nostr-fetch';
import { useMemo } from 'react';
import { VList } from 'virtua';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { MemoizedFileNote } from '@shared/notes';
@@ -16,8 +14,7 @@ import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
export function FileWidget({ widget }: { widget: Widget }) {
const { db } = useStorage();
const { ndk, relayUrls, fetcher } = useNDK();
const { ark } = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['media', widget.id],
@@ -39,20 +36,19 @@ export function FileWidget({ widget }: { widget: Widget }) {
} else {
filter = {
kinds: [1063],
authors: db.account.contacts,
authors: ark.account.contacts,
};
}
const events = await fetcher.fetchLatestEvents(relayUrls, filter, FETCH_LIMIT, {
asOf: pageParam === 0 ? undefined : pageParam,
abortSignal: signal,
const events = await ark.getInfiniteEvents({
filter,
limit: FETCH_LIMIT,
pageParam,
signal,
dedup: false,
});
const ndkEvents = events.map((event) => {
return new NDKEvent(ndk, event);
});
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);

View File

@@ -3,7 +3,7 @@ import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { VList } from 'virtua';
import { useNDK } from '@libs/ndk/provider';
import { useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import {
@@ -19,7 +19,7 @@ import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
export function GroupWidget({ widget }: { widget: Widget }) {
const { relayUrls, ndk, fetcher } = useNDK();
const { ark } = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['groupfeeds', widget.id],
@@ -32,21 +32,18 @@ export function GroupWidget({ widget }: { widget: Widget }) {
pageParam: number;
}) => {
const authors = JSON.parse(widget.content);
const events = await fetcher.fetchLatestEvents(
relayUrls,
{
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: authors,
},
FETCH_LIMIT,
{ asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal }
);
const ndkEvents = events.map((event) => {
return new NDKEvent(ndk, event);
limit: FETCH_LIMIT,
pageParam,
signal,
dedup: false,
});
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);

View File

@@ -3,7 +3,7 @@ import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { VList } from 'virtua';
import { useNDK } from '@libs/ndk/provider';
import { useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { MemoizedRepost, MemoizedTextNote, UnknownNote } from '@shared/notes';
@@ -14,7 +14,7 @@ import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
export function HashtagWidget({ widget }: { widget: Widget }) {
const { ndk, relayUrls, fetcher } = useNDK();
const { ark } = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['hashtag', widget.id],
@@ -26,21 +26,18 @@ export function HashtagWidget({ widget }: { widget: Widget }) {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await fetcher.fetchLatestEvents(
relayUrls,
{
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
'#t': [widget.content],
},
FETCH_LIMIT,
{ asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal }
);
const ndkEvents = events.map((event) => {
return new NDKEvent(ndk, event);
limit: FETCH_LIMIT,
pageParam,
signal,
dedup: false,
});
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);

View File

@@ -3,8 +3,7 @@ import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo, useRef } from 'react';
import { VList, VListHandle } from 'virtua';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import {
@@ -19,8 +18,7 @@ import { LiveUpdater, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants';
export function NewsfeedWidget() {
const { db } = useStorage();
const { relayUrls, ndk, fetcher } = useNDK();
const { ark } = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['newsfeed'],
@@ -32,35 +30,19 @@ export function NewsfeedWidget() {
signal: AbortSignal;
pageParam: number;
}) => {
const rootIds = new Set();
const dedupQueue = new Set();
const events = await fetcher.fetchLatestEvents(
relayUrls,
{
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: db.account.contacts,
authors: !ark.account.contacts.length
? [ark.account.pubkey]
: ark.account.contacts,
},
FETCH_LIMIT,
{ asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal }
);
const ndkEvents = events.map((event) => {
return new NDKEvent(ndk, event);
limit: FETCH_LIMIT,
pageParam,
signal,
});
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);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);

View File

@@ -1,10 +1,9 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKKind, NDKSubscription } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo } from 'react';
import { VList } from 'virtua';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { MemoizedNotifyNote, NoteSkeleton } from '@shared/notes';
@@ -12,15 +11,12 @@ import { TitleBar } from '@shared/titleBar';
import { WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants';
import { useNostr } from '@utils/hooks/useNostr';
import { sendNativeNotification } from '@utils/notification';
export function NotificationWidget() {
const queryClient = useQueryClient();
const { db } = useStorage();
const { sub } = useNostr();
const { ndk, relayUrls, fetcher } = useNDK();
const { ark } = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['notification'],
@@ -32,21 +28,17 @@ export function NotificationWidget() {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await fetcher.fetchLatestEvents(
relayUrls,
{
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
'#p': [db.account.pubkey],
'#p': [ark.account.pubkey],
},
FETCH_LIMIT,
{ asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal }
);
const ndkEvents = events.map((event) => {
return new NDKEvent(ndk, event);
limit: FETCH_LIMIT,
pageParam,
signal,
});
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);
@@ -65,21 +57,24 @@ export function NotificationWidget() {
);
const renderEvent = useCallback((event: NDKEvent) => {
if (event.pubkey === db.account.pubkey) return null;
if (event.pubkey === ark.account.pubkey) return null;
return <MemoizedNotifyNote key={event.id} event={event} />;
}, []);
useEffect(() => {
if (status === 'success' && db.account) {
let sub: NDKSubscription = undefined;
if (status === 'success' && ark.account) {
const filter = {
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
'#p': [db.account.pubkey],
'#p': [ark.account.pubkey],
since: Math.floor(Date.now() / 1000),
};
sub(
sub = ark.subscribe({
filter,
async (event) => {
closeOnEose: false,
cb: async (event) => {
queryClient.setQueryData(
['notification'],
(prev: { pageParams: number; pages: Array<NDKEvent[]> }) => ({
@@ -88,21 +83,18 @@ export function NotificationWidget() {
})
);
const user = ndk.getUser({ pubkey: event.pubkey });
await user.fetchProfile();
const profile = await ark.getUserProfile({ pubkey: event.pubkey });
switch (event.kind) {
case NDKKind.Text:
return await sendNativeNotification(
`${
user.profile.displayName || user.profile.name
} has replied to your note`
`${profile.displayName || profile.name} has replied to your note`
);
case NDKKind.EncryptedDirectMessage: {
if (location.pathname !== '/chats') {
return await sendNativeNotification(
`${
user.profile.displayName || user.profile.name
profile.displayName || profile.name
} has send you a encrypted message`
);
} else {
@@ -111,28 +103,28 @@ export function NotificationWidget() {
}
case NDKKind.Repost:
return await sendNativeNotification(
`${
user.profile.displayName || user.profile.name
} has reposted to your note`
`${profile.displayName || profile.name} has reposted to your note`
);
case NDKKind.Reaction:
return await sendNativeNotification(
`${user.profile.displayName || user.profile.name} has reacted ${
`${profile.displayName || profile.name} has reacted ${
event.content
} to your note`
);
case NDKKind.Zap:
return await sendNativeNotification(
`${user.profile.displayName || user.profile.name} has zapped to your note`
`${profile.displayName || profile.name} has zapped to your note`
);
default:
break;
}
},
false,
'notification'
);
});
}
return () => {
if (sub) sub.stop();
};
}, [status]);
return (

View File

@@ -1,7 +1,7 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import {
ArrowRightCircleIcon,
@@ -16,7 +16,7 @@ import { WIDGET_KIND } from '@utils/constants';
import { useWidget } from '@utils/hooks/useWidget';
export function AddGroupFeeds({ currentWidgetId }: { currentWidgetId: string }) {
const { db } = useStorage();
const { ark } = useArk();
const { replaceWidget } = useWidget();
const [title, setTitle] = useState<string>('');
@@ -95,7 +95,7 @@ export function AddGroupFeeds({ currentWidgetId }: { currentWidgetId: string })
Users
</span>
<div className="flex h-[420px] flex-col overflow-y-auto rounded-xl bg-neutral-100 py-2 dark:bg-neutral-900">
{db.account.contacts.map((item: string) => (
{ark.account?.contacts?.map((item: string) => (
<button
key={item}
type="button"

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,17 +27,16 @@ export function LiveUpdater({ status }: { status: QueryStatus }) {
useEffect(() => {
let sub: NDKSubscription = undefined;
if (status === 'success' && db.account && db.account?.contacts?.length > 0) {
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

@@ -2,6 +2,8 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useCallback } from 'react';
import { WVList } from 'virtua';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import {
ChildNote,
@@ -17,16 +19,15 @@ import { User } from '@shared/user';
import { WidgetWrapper } from '@shared/widgets';
import { useEvent } from '@utils/hooks/useEvent';
import { useNostr } from '@utils/hooks/useNostr';
import { Widget } from '@utils/types';
export function ThreadWidget({ widget }: { widget: Widget }) {
const { isFetching, isError, data } = useEvent(widget.content);
const { getEventThread } = useNostr();
const { ark } = useArk();
const renderKind = useCallback(
(event: NDKEvent) => {
const thread = getEventThread(event.tags);
const thread = ark.getEventThread({ tags: event.tags });
switch (event.kind) {
case NDKKind.Text:
return (

View File

@@ -1,10 +1,9 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKFilter, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { FetchFilter } from 'nostr-fetch';
import { useCallback, useMemo } from 'react';
import { VList } from 'virtua';
import { useNDK } from '@libs/ndk/provider';
import { useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import {
@@ -20,7 +19,7 @@ import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
export function TopicWidget({ widget }: { widget: Widget }) {
const { relayUrls, ndk, fetcher } = useNDK();
const { ark } = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['topic', widget.id],
@@ -33,35 +32,19 @@ export function TopicWidget({ widget }: { widget: Widget }) {
pageParam: number;
}) => {
const hashtags: string[] = JSON.parse(widget.content as string);
const filter: FetchFilter = {
const filter: NDKFilter = {
kinds: [NDKKind.Text, NDKKind.Repost],
'#t': hashtags.map((tag) => tag.replace('#', '')),
};
const rootIds = new Set();
const dedupQueue = new Set();
const events = await fetcher.fetchLatestEvents(relayUrls, filter, FETCH_LIMIT, {
asOf: pageParam === 0 ? undefined : pageParam,
abortSignal: signal,
const events = await ark.getInfiniteEvents({
filter,
limit: FETCH_LIMIT,
pageParam,
signal,
});
const ndkEvents = events.map((event) => {
return new NDKEvent(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);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);

View File

@@ -1,10 +1,11 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { WVList } from 'virtua';
import { useNDK } from '@libs/ndk/provider';
import { useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import {
MemoizedRepost,
MemoizedTextNote,
@@ -15,43 +16,46 @@ import { TitleBar } from '@shared/titleBar';
import { UserProfile } from '@shared/userProfile';
import { WidgetWrapper } from '@shared/widgets';
import { nHoursAgo } from '@utils/date';
import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
export function UserWidget({ widget }: { widget: Widget }) {
const { ndk } = useNDK();
const { status, data } = useQuery({
queryKey: ['user-posts', widget.id],
queryFn: async () => {
const rootIds = new Set();
const dedupQueue = new Set();
const { ark } = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['user-posts', widget.content],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: [widget.content],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
const events = await ndk.fetchEvents({
kinds: [NDKKind.Text, NDKKind.Repost],
authors: [widget.content],
since: nHoursAgo(24),
});
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
refetchOnWindowFocus: false,
});
const ndkEvents = [...events];
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);
},
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data]
);
// render event match event kind
const renderItem = useCallback(
@@ -86,19 +90,28 @@ export function UserWidget({ widget }: { widget: Widget }) {
<NoteSkeleton />
</div>
</div>
) : data.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-6 dark:bg-neutral-900">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm text-neutral-900 dark:text-neutral-100">
No new post from 24 hours ago
</p>
</div>
</div>
</div>
) : (
data.map((item) => renderItem(item))
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</div>
</div>
</WVList>

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,299 +0,0 @@
import {
NDKEvent,
NDKFilter,
NDKKind,
NDKSubscription,
NDKTag,
} from '@nostr-dev-kit/ndk';
import { open } from '@tauri-apps/plugin-dialog';
import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { fetch } from '@tauri-apps/plugin-http';
import { LRUCache } from 'lru-cache';
import { NostrEventExt } from 'nostr-fetch';
import { useMemo } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { nHoursAgo } from '@utils/date';
import { getMultipleRandom } from '@utils/transform';
import { NDKEventWithReplies } from '@utils/types';
export function useNostr() {
const { db } = useStorage();
const { ndk, relayUrls, fetcher } = useNDK();
const subManager = useMemo(
() =>
new LRUCache<string, NDKSubscription, void>({
max: 4,
dispose: (sub) => sub.stop(),
}),
[]
);
const sub = async (
filter: NDKFilter,
callback: (event: NDKEvent) => void,
groupable?: boolean,
subKey?: string
) => {
if (!ndk) throw new Error('NDK instance not found');
const key = subKey ?? JSON.stringify(filter);
if (!subManager.get(key)) {
const subEvent = ndk.subscribe(filter, {
closeOnEose: false,
groupable: groupable ?? true,
});
subEvent.addListener('event', (event: NDKEvent) => {
callback(event);
});
subManager.set(JSON.stringify(filter), subEvent);
console.log('sub: ', key);
}
};
const getEventThread = (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,
};
};
const getAllActivities = async (limit?: number) => {
try {
const events = await ndk.fetchEvents({
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
'#p': [db.account.pubkey],
limit: limit ?? 50,
});
return [...events];
} catch (e) {
console.error('Error fetching activities', e);
}
};
const fetchNIP04Messages = async (sender: string) => {
let senderMessages: NostrEventExt<false>[] = [];
if (sender !== db.account.pubkey) {
senderMessages = await fetcher.fetchAllEvents(
relayUrls,
{
kinds: [NDKKind.EncryptedDirectMessage],
authors: [sender],
'#p': [db.account.pubkey],
},
{ since: 0 }
);
}
const userMessages = await fetcher.fetchAllEvents(
relayUrls,
{
kinds: [NDKKind.EncryptedDirectMessage],
authors: [db.account.pubkey],
'#p': [sender],
},
{ since: 0 }
);
const all = [...senderMessages, ...userMessages].sort(
(a, b) => a.created_at - b.created_at
);
return all as unknown as NDKEvent[];
};
const fetchAllReplies = async (id: string, data?: NDKEventWithReplies[]) => {
let events = data || null;
if (!data) {
events = (await fetcher.fetchAllEvents(
relayUrls,
{
kinds: [NDKKind.Text],
'#e': [id],
},
{ since: 0 },
{ sort: true }
)) as unknown 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;
};
const getAllNIP04Chats = async () => {
const events = await fetcher.fetchAllEvents(
relayUrls,
{
kinds: [NDKKind.EncryptedDirectMessage],
'#p': [db.account.pubkey],
},
{ since: 0 }
);
const dedup: NDKEvent[] = Object.values(
events.reduce((ev, { id, content, pubkey, created_at, tags }) => {
if (ev[pubkey]) {
if (ev[pubkey].created_at < created_at) {
ev[pubkey] = { id, content, pubkey, created_at, tags };
}
} else {
ev[pubkey] = { id, content, pubkey, created_at, tags };
}
return ev;
}, {})
);
return dedup;
};
const getContactsByPubkey = async (pubkey: string) => {
const user = ndk.getUser({ pubkey: pubkey });
const follows = [...(await user.follows())].map((user) => user.hexpubkey);
return getMultipleRandom([...follows], 10);
};
const getEventsByPubkey = async (pubkey: string) => {
const events = await fetcher.fetchAllEvents(
relayUrls,
{ authors: [pubkey], kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Article] },
{ since: nHoursAgo(24) },
{ sort: true }
);
return events as unknown as NDKEvent[];
};
const getAllRelaysByUsers = async () => {
const relayMap = new Map<string, string[]>();
const relayEvents = fetcher.fetchLatestEventsPerAuthor(
{
authors: db.account.contacts,
relayUrls: relayUrls,
},
{ kinds: [NDKKind.RelayList] },
5
);
for await (const { author, events } of relayEvents) {
if (events[0]) {
events[0].tags.forEach((tag) => {
const users = relayMap.get(tag[1]);
if (!users) return relayMap.set(tag[1], [author]);
return users.push(author);
});
}
}
return relayMap;
};
const createZap = async (event: NDKEvent, amount: number, message?: string) => {
// @ts-expect-error, NostrEvent to NDKEvent
const ndkEvent = new NDKEvent(ndk, event);
const res = await ndkEvent.zap(amount, message ?? 'zap from lume');
return res;
};
const upload = async (ext: string[] = []) => {
const defaultExts = ['png', 'jpeg', 'jpg', 'gif'].concat(ext);
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;
};
return {
sub,
getEventThread,
getAllNIP04Chats,
getContactsByPubkey,
getEventsByPubkey,
getAllRelaysByUsers,
getAllActivities,
fetchNIP04Messages,
fetchAllReplies,
createZap,
upload,
};
}

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,
@@ -23,9 +23,19 @@ const NOSTR_MENTIONS = [
'npub1',
'nprofile1',
'naddr1',
'Nostr:npub1',
'Nostr:nprofile1',
'Nostr:naddre1',
];
const NOSTR_EVENTS = ['nostr:note1', 'note1', 'nostr:nevent1', 'nevent1'];
const NOSTR_EVENTS = [
'nostr:note1',
'note1',
'nostr:nevent1',
'nevent1',
'Nostr:note1',
'Nostr:nevent1',
];
// const BITCOINS = ['lnbc', 'bc1p', 'bc1q'];
@@ -46,7 +56,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 +68,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 +100,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

@@ -4,14 +4,14 @@ import tippy from 'tippy.js';
import { MentionList } from '@app/new/components';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
export function useSuggestion() {
const { db } = useStorage();
const { ark } = useArk();
const suggestion: MentionOptions['suggestion'] = {
items: async ({ query }) => {
const users = await db.getAllCacheUsers();
const users = await ark.getAllCacheUsers();
return users
.filter((item) => {
if (item.name) return item.name.toLowerCase().startsWith(query.toLowerCase());

View File

@@ -1,16 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useStorage } from '@libs/storage/provider';
import { useArk } from '@libs/ark';
import { Widget } from '@utils/types';
export function useWidget() {
const { db } = useStorage();
const { ark } = useArk();
const queryClient = useQueryClient();
const addWidget = useMutation({
mutationFn: async (widget: Widget) => {
return await db.createWidget(widget.kind, widget.title, widget.content);
return await ark.createWidget(widget.kind, widget.title, widget.content);
},
onSuccess: (data) => {
queryClient.setQueryData(['widgets'], (old: Widget[]) => [...old, data]);
@@ -26,8 +26,8 @@ export function useWidget() {
const prevWidgets = queryClient.getQueryData(['widgets']);
// create new widget
await db.removeWidget(currentId);
const newWidget = await db.createWidget(widget.kind, widget.title, widget.content);
await ark.removeWidget(currentId);
const newWidget = await ark.createWidget(widget.kind, widget.title, widget.content);
// Optimistically update to the new value
queryClient.setQueryData(['widgets'], (prev: Widget[]) => [
@@ -57,7 +57,7 @@ export function useWidget() {
);
// Update in database
await db.removeWidget(id);
await ark.removeWidget(id);
// Return a context object with the snapshotted value
return { prevWidgets };

View File

@@ -6,7 +6,7 @@ export function shortenKey(pubkey: string) {
}
export function displayNpub(pubkey: string, len: number, separator?: string) {
const npub = nip19.npubEncode(pubkey) as string;
const npub = pubkey.startsWith('npub1') ? pubkey : (nip19.npubEncode(pubkey) as string);
if (npub.length <= len) return npub;
separator = separator || ' ... ';

View File

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