chore: monorepo

This commit is contained in:
2023-12-25 14:28:39 +07:00
parent a6da07cd3f
commit 227c2ddefa
374 changed files with 19966 additions and 12758 deletions

View File

@@ -1,52 +0,0 @@
/* Vidstack */
@import '@vidstack/react/player/styles/default/theme.css';
@import '@vidstack/react/player/styles/default/layouts/video.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.break-p {
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
}
.prose :where(iframe):not(:where([class~='not-prose'] *)) {
@apply mx-auto aspect-video h-auto w-full;
}
}
html {
font-size: 14px;
}
a {
@apply cursor-default no-underline !important;
}
button {
@apply cursor-default focus:outline-none;
}
input::-ms-reveal,
input::-ms-clear {
display: none;
}
::-webkit-input-placeholder {
line-height: normal;
}
.border {
background-clip: padding-box;
}
.ProseMirror p.is-empty::before {
@apply pointer-events-none float-left h-0 text-neutral-600 content-[attr(data-placeholder)] dark:text-neutral-400;
}
.ProseMirror img.ProseMirror-selectednode {
@apply outline-blue-500;
}

View File

@@ -1,281 +0,0 @@
import { fetch } from '@tauri-apps/plugin-http';
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
import { ErrorScreen } from '@app/error';
import { useStorage } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { AppLayout } from '@shared/layouts/app';
import { AuthLayout } from '@shared/layouts/auth';
import { ComposerLayout } from '@shared/layouts/composer';
import { HomeLayout } from '@shared/layouts/home';
import { SettingsLayout } from '@shared/layouts/settings';
export default function App() {
const storage = useStorage();
const router = createBrowserRouter([
{
element: <AppLayout platform={storage.platform} />,
children: [
{
path: '/',
element: <HomeLayout />,
errorElement: <ErrorScreen />,
loader: async () => {
if (!storage.account) return redirect('auth/welcome');
return null;
},
children: [
{
index: true,
async lazy() {
const { HomeScreen } = await import('@app/home');
return { Component: HomeScreen };
},
},
{
path: 'nwc',
async lazy() {
const { NWCScreen } = await import('@app/nwc');
return { Component: NWCScreen };
},
},
{
path: 'relays',
async lazy() {
const { RelaysScreen } = await import('@app/relays');
return { Component: RelaysScreen };
},
},
{
path: 'relays/:url',
loader: async ({ params }) => {
return defer({
relay: fetch(`https://${params.url}`, {
method: 'GET',
headers: {
Accept: 'application/nostr+json',
},
}).then((res) => res.json()),
});
},
async lazy() {
const { RelayScreen } = await import('@app/relays/relay');
return { Component: RelayScreen };
},
},
{
path: 'new',
element: <ComposerLayout />,
children: [
{
index: true,
async lazy() {
const { NewPostScreen } = await import('@app/new/post');
return { Component: NewPostScreen };
},
},
{
path: 'article',
async lazy() {
const { NewArticleScreen } = await import('@app/new/article');
return { Component: NewArticleScreen };
},
},
{
path: 'file',
async lazy() {
const { NewFileScreen } = await import('@app/new/file');
return { Component: NewFileScreen };
},
},
{
path: 'privkey',
async lazy() {
const { NewPrivkeyScreen } = await import('@app/new/privkey');
return { Component: NewPrivkeyScreen };
},
},
],
},
{
path: 'settings',
element: <SettingsLayout />,
children: [
{
index: true,
async lazy() {
const { UserSettingScreen } = await import('@app/settings');
return { Component: UserSettingScreen };
},
},
{
path: 'edit-profile',
async lazy() {
const { EditProfileScreen } = await import(
'@app/settings/editProfile'
);
return { Component: EditProfileScreen };
},
},
{
path: 'edit-contact',
async lazy() {
const { EditContactScreen } = await import(
'@app/settings/editContact'
);
return { Component: EditContactScreen };
},
},
{
path: 'general',
async lazy() {
const { GeneralSettingScreen } = await import(
'@app/settings/general'
);
return { Component: GeneralSettingScreen };
},
},
{
path: 'backup',
async lazy() {
const { BackupSettingScreen } = await import('@app/settings/backup');
return { Component: BackupSettingScreen };
},
},
{
path: 'advanced',
async lazy() {
const { AdvancedSettingScreen } = await import(
'@app/settings/advanced'
);
return { Component: AdvancedSettingScreen };
},
},
{
path: 'about',
async lazy() {
const { AboutScreen } = await import('@app/settings/about');
return { Component: AboutScreen };
},
},
],
},
],
},
{
path: 'depot',
children: [
{
index: true,
loader: () => {
const depot = storage.checkDepot();
if (!depot) return redirect('/depot/onboarding/');
return null;
},
async lazy() {
const { DepotScreen } = await import('@app/depot');
return { Component: DepotScreen };
},
},
{
path: 'onboarding',
async lazy() {
const { DepotOnboardingScreen } = await import('@app/depot/onboarding');
return { Component: DepotOnboardingScreen };
},
},
],
},
],
},
{
path: 'auth',
element: <AuthLayout platform={storage.platform} />,
errorElement: <ErrorScreen />,
children: [
{
path: 'welcome',
async lazy() {
const { WelcomeScreen } = await import('@app/auth/welcome');
return { Component: WelcomeScreen };
},
},
{
path: 'create',
async lazy() {
const { CreateAccountScreen } = await import('@app/auth/create');
return { Component: CreateAccountScreen };
},
},
{
path: 'import',
async lazy() {
const { ImportAccountScreen } = await import('@app/auth/import');
return { Component: ImportAccountScreen };
},
},
{
path: 'onboarding',
async lazy() {
const { OnboardingScreen } = await import('@app/auth/onboarding');
return { Component: OnboardingScreen };
},
},
{
path: 'follow',
async lazy() {
const { FollowScreen } = await import('@app/auth/follow');
return { Component: FollowScreen };
},
},
{
path: 'finish',
async lazy() {
const { FinishScreen } = await import('@app/auth/finish');
return { Component: FinishScreen };
},
},
{
path: 'tutorials/note',
async lazy() {
const { TutorialNoteScreen } = await import('@app/auth/tutorials/note');
return { Component: TutorialNoteScreen };
},
},
{
path: 'tutorials/widget',
async lazy() {
const { TutorialWidgetScreen } = await import('@app/auth/tutorials/widget');
return { Component: TutorialWidgetScreen };
},
},
{
path: 'tutorials/posting',
async lazy() {
const { TutorialPostingScreen } = await import('@app/auth/tutorials/posting');
return { Component: TutorialPostingScreen };
},
},
{
path: 'tutorials/finish',
async lazy() {
const { TutorialFinishScreen } = await import('@app/auth/tutorials/finish');
return { Component: TutorialFinishScreen };
},
},
],
},
]);
return (
<RouterProvider
router={router}
fallbackElement={
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-6 w-6 animate-spin" />
</div>
}
future={{ v7_startTransition: true }}
/>
);
}

View File

@@ -1,43 +0,0 @@
import { message } from '@tauri-apps/plugin-dialog';
import { Dispatch, SetStateAction, useState } from 'react';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
export function AvatarUploader({
setPicture,
}: {
setPicture: Dispatch<SetStateAction<string>>;
}) {
const ark = useArk();
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {
try {
// start loading
setLoading(true);
const image = await ark.upload({});
if (image) {
setPicture(image);
setLoading(false);
}
return;
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
return (
<button
type="button"
onClick={() => uploadAvatar()}
className="inline-flex items-center justify-center rounded-lg border border-blue-200 bg-blue-100 px-2 py-1.5 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800"
>
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Change avatar'}
</button>
);
}

View File

@@ -1,302 +0,0 @@
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';
import { writeTextFile } from '@tauri-apps/plugin-fs';
import { motion } from 'framer-motion';
import { minidenticon } from 'minidenticons';
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { AvatarUploader } from '@app/auth/components/avatarUploader';
import { useArk } from '@libs/ark';
import { ArrowLeftIcon, InfoIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
export function CreateAccountScreen() {
const [picture, setPicture] = useState('');
const [downloaded, setDownloaded] = useState(false);
const [loading, setLoading] = useState(false);
const [keys, setKeys] = useState<null | {
npub: string;
nsec: string;
}>(null);
const ark = useArk();
const {
register,
handleSubmit,
formState: { isDirty, isValid },
} = useForm();
const navigate = useNavigate();
const svgURI =
'data:image/svg+xml;utf8,' +
encodeURIComponent(minidenticon('lume new account', 90, 50));
const onSubmit = async (data: { name: string; about: string }) => {
try {
setLoading(true);
const profile = {
...data,
name: data.name,
display_name: data.name,
bio: data.about,
picture: picture,
avatar: picture,
};
const userPrivkey = generatePrivateKey();
const userPubkey = getPublicKey(userPrivkey);
const userNpub = nip19.npubEncode(userPubkey);
const userNsec = nip19.nsecEncode(userPrivkey);
const signer = new NDKPrivateKeySigner(userPrivkey);
ark.updateNostrSigner({ signer });
const publish = await ark.createEvent({
content: JSON.stringify(profile),
kind: NDKKind.Metadata,
tags: [],
});
if (publish) {
await ark.createAccount({
id: userNpub,
pubkey: userPubkey,
privkey: userPrivkey,
});
setKeys({ npub: userNpub, nsec: userNsec });
setLoading(false);
} else {
toast.error('Cannot publish user profile, please try again later.');
setLoading(false);
}
} catch (e) {
return toast.error(e);
}
};
const copyNsec = async () => {
await writeText(keys.nsec);
};
const download = async () => {
try {
const downloadPath = await downloadDir();
const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
const filePath = await save({
defaultPath: downloadPath + '/' + fileName,
});
if (filePath) {
await writeTextFile(
filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${keys.npub}\nPrivate key: ${keys.nsec}`
);
setDownloaded(true);
} // else { user cancel action }
} catch (e) {
return toast.error(e);
}
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="absolute left-[8px] top-2">
{!keys ? (
<button
onClick={() => navigate(-1)}
className="group inline-flex items-center gap-2 text-sm font-medium"
>
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 group-hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-200 dark:group-hover:bg-neutral-700">
<ArrowLeftIcon className="h-4 w-4" />
</div>
Back
</button>
) : null}
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<h1 className="text-center text-2xl font-semibold">
Let&apos;s set up your account.
</h1>
<div className="flex flex-col gap-3">
{!keys ? (
<div className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<input type={'hidden'} {...register('picture')} value={picture} />
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="font-semibold">Avatar</span>
<div className="flex h-36 w-full flex-col items-center justify-center gap-3 rounded-lg bg-neutral-100 dark:bg-neutral-900">
{picture.length > 0 ? (
<img
src={picture}
alt="user's avatar"
className="h-14 w-14 rounded-xl object-cover"
/>
) : (
<img
src={svgURI}
alt="user's avatar"
className="h-14 w-14 rounded-xl bg-black dark:bg-white"
/>
)}
<AvatarUploader setPicture={setPicture} />
</div>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="name" className="font-semibold">
Name *
</label>
<input
type={'text'}
{...register('name', {
required: true,
minLength: 1,
})}
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="about" className="font-semibold">
Bio
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-lg bg-blue-100 p-3 text-sm text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<InfoIcon className="h-8 w-8" />
<p>
There are many more settings you can configure from the
&quot;Settings&quot; screen. Be sure to visit it later.
</p>
</div>
<button
type="submit"
disabled={!isDirty || !isValid}
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 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
'Create and Continue'
)}
</button>
</div>
</div>
</form>
</div>
) : (
<>
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{
opacity: 1,
y: 0,
}}
className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
>
<User pubkey={keys.npub} variant="simple" />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 80 }}
animate={{
opacity: 1,
y: 0,
}}
className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
>
<div className="flex flex-col gap-1.5">
<h5 className="font-semibold">Backup account</h5>
<div>
<p className="mb-2 select-text text-sm text-neutral-800 dark:text-neutral-200">
Your private key is your password. If you lose this key, you will
lose access to your account! Copy it and keep it in a safe place.{' '}
<span className="text-red-500">
There is no way to reset your private key.
</span>
</p>
<p className="select-text text-sm text-neutral-800 dark:text-neutral-200">
Public key is used for sharing with other people so that they can
find you using the public key.
</p>
</div>
<div className="mt-3 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="nsec" className="text-sm font-semibold">
Private key
</label>
<div className="relative w-full">
<input
readOnly
value={
keys.nsec.substring(0, 10) + '**************************'
}
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2">
<button
type="button"
onClick={copyNsec}
className="rounded-md bg-neutral-200 px-2 py-1 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
>
Copy
</button>
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="nsec" className="text-sm font-semibold">
Public key
</label>
<input
readOnly
value={keys.npub}
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
</div>
{!downloaded ? (
<button
type="button"
onClick={() => download()}
className="mt-1 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"
>
Download account keys
</button>
) : null}
</div>
</motion.div>
</>
)}
{downloaded ? (
<motion.button
initial={{ opacity: 0, y: 50 }}
animate={{
opacity: 1,
y: 0,
}}
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"
type="button"
onClick={() => navigate('/auth/onboarding')}
>
Finish
</motion.button>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -1,34 +0,0 @@
import { Link } from 'react-router-dom';
export function FinishScreen() {
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">
<div className="text-center">
<img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" />
<h1 className="text-2xl font-light">
Yo, you&apos;re ready to use <span className="font-bold">Lume</span>
</h1>
</div>
<div className="flex flex-col gap-2">
<Link
to="/auth/tutorials/note"
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 tutorial
</Link>
<Link
to="/"
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>
<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
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,266 +0,0 @@
import * as Accordion from '@radix-ui/react-accordion';
import { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge';
import { useArk } from '@libs/ark';
import {
ArrowLeftIcon,
ArrowRightIcon,
CancelIcon,
ChevronDownIcon,
LoaderIcon,
PlusIcon,
} from '@shared/icons';
import { User } from '@shared/user';
const POPULAR_USERS = [
'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6',
'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
'npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s',
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
'npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8',
'npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a',
'npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc',
'npub133vj8ycevdle0cq8mtgddq0xtn34kxkwxvak983dx0u5vhqnycyqj6tcza',
'npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424',
'npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac',
'npub1prya33fnqerq0fljwjtp77ehtu7jlsjt5ydhwveuwmqdsdm6k8esk42xcv',
'npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk',
];
const LUME_USERS = ['npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445'];
export function FollowScreen() {
const ark = useArk();
const navigate = useNavigate();
const { status, data } = useQuery({
queryKey: ['trending-profiles-widget'],
queryFn: async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
throw new Error('Error');
}
return res.json();
},
});
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState<string[]>([]);
// toggle follow state
const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey)
? follows.filter((i) => i !== pubkey)
: [...follows, pubkey];
setFollows(arr);
};
const submit = async () => {
try {
setLoading(true);
if (!follows.length) return navigate('/auth/finish');
const publish = await ark.newContactList({
tags: follows.map((item) => {
if (item.startsWith('npub1')) return ['p', nip19.decode(item).data as string];
return ['p', item];
}),
});
if (publish) {
setLoading(false);
return navigate('/auth/finish');
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<h1 className="text-2xl font-semibold">Dive into the nostrverse</h1>
<h2 className="text-neutral-700 dark:text-neutral-300">
Try following some users that interest you
<br />
to build up your timeline.
</h2>
</div>
<Accordion.Root type="single" defaultValue="recommended" collapsible>
<Accordion.Item value="recommended" className="mb-3 overflow-hidden rounded-xl">
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
Popular users
<ChevronDownIcon className="h-4 w-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
{POPULAR_USERS.map((pubkey) => (
<div
key={pubkey}
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
>
<div className="p-3">
<User pubkey={pubkey} variant="large" />
</div>
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(pubkey)}
className={twMerge(
'inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white',
follows.includes(pubkey)
? 'bg-red-500 hover:bg-red-600'
: 'bg-blue-500 hover:bg-blue-600'
)}
>
{follows.includes(pubkey) ? (
<>
<CancelIcon className="h-4 w-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Follow
</>
)}
</button>
</div>
</div>
))}
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="trending" className="mb-3 overflow-hidden rounded-xl">
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
Trending users
<ChevronDownIcon className="h-4 w-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
data?.profiles.map(
(item: { pubkey: string; profile: { content: string } }) => (
<div
key={item.pubkey}
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
>
<div className="p-3">
<User
pubkey={item.pubkey}
variant="large"
embedProfile={item.profile?.content}
/>
</div>
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(item.pubkey)}
className={twMerge(
'inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white',
follows.includes(item.pubkey)
? 'bg-red-500 hover:bg-red-600'
: 'bg-blue-500 hover:bg-blue-600'
)}
>
{follows.includes(item.pubkey) ? (
<>
<CancelIcon className="h-4 w-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Follow
</>
)}
</button>
</div>
</div>
)
)
)}
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="lume" className="mb-3 overflow-hidden rounded-xl">
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
Lume team
<ChevronDownIcon className="h-4 w-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
{LUME_USERS.map((pubkey) => (
<div
key={pubkey}
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
>
<div className="p-3">
<User pubkey={pubkey} variant="large" />
</div>
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(pubkey)}
className={twMerge(
'inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white',
follows.includes(pubkey)
? 'bg-red-500 hover:bg-red-600'
: 'bg-blue-500 hover:bg-blue-600'
)}
>
{follows.includes(pubkey) ? (
<>
<CancelIcon className="h-4 w-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Follow
</>
)}
</button>
</div>
</div>
))}
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</div>
<div className="absolute bottom-3 right-3 flex w-full items-center justify-end gap-2">
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex h-11 w-max items-center justify-center gap-2 rounded-lg bg-neutral-100 px-3 font-semibold hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-blue-800"
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</button>
<button
type="button"
onClick={submit}
disabled={loading}
className="inline-flex h-11 w-max items-center justify-center gap-2 rounded-lg bg-blue-500 px-3 font-semibold text-white hover:bg-blue-600"
>
Continue
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<ArrowRightIcon className="h-4 w-4" />
)}
</button>
</div>
</div>
);
}

View File

@@ -1,320 +0,0 @@
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { readText } from '@tauri-apps/plugin-clipboard-manager';
import { open } from '@tauri-apps/plugin-shell';
import { motion } from 'framer-motion';
import { nip19 } from 'nostr-tools';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge';
import { useArk, useStorage } from '@libs/ark';
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
export function ImportAccountScreen() {
const [npub, setNpub] = useState<string>('');
const [nsec, setNsec] = useState<string>('');
const [pubkey, setPubkey] = useState<undefined | string>(undefined);
const [loading, setLoading] = useState(false);
const [created, setCreated] = useState({ ok: false, remote: false });
const [savedPrivkey, setSavedPrivkey] = useState(false);
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const submitNpub = async () => {
if (npub.length < 6) return toast.error('You must enter valid npub');
if (!npub.startsWith('npub1')) return toast.error('npub must be starts with npub1');
try {
const pubkey = nip19.decode(npub).data as string;
setPubkey(pubkey);
} catch (e) {
return toast.error(`npub invalid: ${e}`);
}
};
const connectNsecBunker = async () => {
if (npub.length < 6) return toast.error('You must enter valid npub');
if (!npub.startsWith('npub1')) return toast.error('npub must be starts with npub1');
try {
const pubkey = nip19.decode(npub.split('#')[0]).data as string;
const localSigner = NDKPrivateKeySigner.generate();
await storage.createSetting('nsecbunker', '1');
await storage.createPrivkey(`${npub}-nsecbunker`, localSigner.privateKey);
// open nsecbunker web app in default browser
await open('https://app.nsecbunker.com/keys');
const bunker = new NDK({
explicitRelayUrls: ['wss://relay.nsecbunker.com', 'wss://nostr.vulpem.com'],
});
await bunker.connect();
const remoteSigner = new NDKNip46Signer(bunker, npub, localSigner);
await remoteSigner.blockUntilReady();
ark.updateNostrSigner({ signer: remoteSigner });
setPubkey(pubkey);
setCreated({ ok: false, remote: true });
} catch (e) {
return toast.error(e);
}
};
const changeAccount = async () => {
setNpub('');
setPubkey('');
};
const createAccount = async () => {
try {
setLoading(true);
// add account to db
await storage.createAccount({ id: npub, pubkey });
// get account contacts
await ark.getUserContacts({ pubkey });
setCreated((prev) => ({ ...prev, ok: true }));
setLoading(false);
if (created.remote) navigate('/auth/onboarding');
} catch (e) {
setLoading(false);
return toast.error(e);
}
};
const pasteNsec = async () => {
const tempNsec = await readText();
setNsec(tempNsec);
};
const submitNsec = async () => {
if (savedPrivkey) return;
if (nsec.length > 50 && nsec.startsWith('nsec1')) {
try {
const privkey = nip19.decode(nsec).data as string;
await storage.createPrivkey(pubkey, privkey);
ark.updateNostrSigner({ signer: new NDKPrivateKeySigner(privkey) });
setSavedPrivkey(true);
} catch (e) {
return toast(`nsec invalid: ${e}`);
}
}
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="absolute left-[8px] top-2">
{!created ? (
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-sm font-medium"
>
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
<ArrowLeftIcon className="h-5 w-5" />
</div>
Back
</button>
) : null}
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<h1 className="text-center text-2xl font-semibold">Import your account.</h1>
<div className="flex flex-col gap-3">
<div className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<div className="flex flex-col gap-1.5">
<label htmlFor="npub" className="font-semibold">
Enter your public key:
</label>
<div className="flex w-full flex-col gap-2">
<input
readOnly={!!pubkey}
name="npub"
type="text"
value={npub}
onChange={(e) => setNpub(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="npub1"
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
{!pubkey ? (
<div className="flex flex-col gap-2">
<button
type="button"
onClick={submitNpub}
className="h-11 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Continue
</button>
<button
type="button"
onClick={connectNsecBunker}
className="h-11 w-full shrink-0 rounded-lg bg-neutral-200 font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
Continue with nsecBunker
</button>
</div>
) : null}
{npub.indexOf('#') > -1 ? (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
You&apos;re using nsecbunker token, keep in mind it only can redeem
one-time, you need to login again in the next launch
</p>
) : null}
</div>
</div>
</div>
{pubkey ? (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{
opacity: 1,
y: 0,
}}
className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
>
<h5 className="mb-1.5 font-semibold">Account found</h5>
<div className="flex w-full flex-col gap-2">
<div className="flex h-full w-full items-center justify-between rounded-lg bg-neutral-100 px-4 py-3 dark:bg-neutral-900">
<User pubkey={pubkey} variant="simple" />
<button
type="button"
onClick={changeAccount}
className="h-8 w-max shrink-0 rounded-lg bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
Change
</button>
</div>
{!created.ok ? (
<button
type="button"
onClick={createAccount}
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"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
'Continue'
)}
</button>
) : null}
</div>
</motion.div>
) : null}
{created.ok ? (
<>
{!created.remote ? (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{
opacity: 1,
y: 0,
}}
className="rounded-lg bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="nsec" className="font-semibold">
Enter your private key (optional):
</label>
<div className="inline-flex w-full items-center gap-2">
<div className="relative flex-1">
<input
name="nsec"
type="text"
value={nsec}
onChange={(e) => setNsec(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="nsec1"
className="h-11 w-full rounded-lg border-transparent bg-neutral-200 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-800 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
{nsec.length < 5 ? (
<div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2">
<button
type="button"
onClick={pasteNsec}
className="rounded-md bg-neutral-300 px-2 py-1 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
>
Paste
</button>
</div>
) : null}
</div>
{nsec.length > 5 ? (
<button
type="button"
onClick={submitNsec}
className={twMerge(
'h-11 w-24 shrink-0 rounded-lg font-semibold text-white',
!savedPrivkey
? 'bg-blue-500 hover:bg-blue-600'
: 'bg-teal-500 hover:bg-teal-600'
)}
>
{savedPrivkey ? 'Saved' : 'Save'}
</button>
) : null}
</div>
</div>
<div className="mt-3 select-text">
<p className="text-sm">
<b>Private Key</b> is used to sign your event. For example, if you
want to make a new post or send a message to your contact, you need
to use your private key to sign this event.
</p>
<h5 className="mt-2 font-semibold">
1. In case you store private key in Lume
</h5>
<p className="text-sm">
Lume will put your private key to{' '}
<b>
{storage.platform === 'macos'
? 'Apple Keychain (macOS)'
: storage.platform === 'windows'
? 'Credential Manager (Windows)'
: 'Secret Service (Linux)'}
</b>
, it will be secured by your OS
</p>
<h5 className="mt-2 font-semibold">
2. In case you do not store private key in Lume
</h5>
<p className="text-sm">
When you make an event that requires a sign by your private key,
Lume will show a prompt for you to enter private key. It will be
cleared after signing and not stored anywhere.
</p>
</div>
</motion.div>
) : null}
<motion.button
initial={{ opacity: 0, y: 80 }}
animate={{
opacity: 1,
y: 0,
}}
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"
type="button"
onClick={() => navigate('/auth/onboarding')}
>
Continue
</motion.button>
</>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -1,145 +0,0 @@
import * as Switch from '@radix-ui/react-switch';
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/ark';
import { InfoIcon } from '@shared/icons';
export function OnboardingScreen() {
const storage = useStorage();
const navigate = useNavigate();
const [settings, setSettings] = useState({
autoupdate: false,
outbox: false,
notification: false,
});
const next = () => {
if (!storage.account.contacts.length) return navigate('/auth/follow');
return navigate('/auth/finish');
};
const toggleOutbox = async () => {
await storage.createSetting('outbox', String(+!settings.outbox));
// update state
setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
};
const toggleAutoupdate = async () => {
await storage.createSetting('autoupdate', String(+!settings.autoupdate));
// update state
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
};
const toggleNofitication = async () => {
await requestPermission();
// update state
setSettings((prev) => ({ ...prev, notification: !settings.notification }));
};
useEffect(() => {
async function loadSettings() {
const permissionGranted = await isPermissionGranted();
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
const data = await storage.getAllSettings();
if (!data) return;
data.forEach((item) => {
if (item.key === 'autoupdate')
setSettings((prev) => ({
...prev,
autoupdate: !!parseInt(item.value),
}));
if (item.key === 'outbox')
setSettings((prev) => ({
...prev,
outbox: !!parseInt(item.value),
}));
});
}
loadSettings();
}, []);
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<h1 className="text-2xl font-light text-neutral-900 dark:text-neutral-100">
You&apos;re almost ready to use Lume.
</h1>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
Let&apos;s start personalizing your experience.
</h2>
</div>
<div className="flex flex-col gap-3">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<Switch.Root
checked={settings.autoupdate}
onClick={() => toggleAutoupdate()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold">Auto check for update on Login</h3>
<p className="text-sm">
Keep Lume up to date with latest version, always have new features and bug
free.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<Switch.Root
checked={settings.notification}
onClick={() => toggleNofitication()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold">Push notification</h3>
<p className="text-sm">
Enabling push notifications will allow you to receive notifications from
Lume directly on your device.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<Switch.Root
checked={settings.outbox}
onClick={() => toggleOutbox()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold">Use Gossip model (recommended)</h3>
<p className="text-sm">
Automatically discover relays to connect based on the preferences of each
author.
</p>
</div>
</div>
<div className="flex items-center gap-2 rounded-lg bg-blue-100 p-3 text-sm text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<InfoIcon className="h-8 w-8" />
<p>
There are many more settings you can configure from the &quot;Settings&quot;
screen. Be sure to visit it later.
</p>
</div>
<button
type="button"
onClick={next}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Continue
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,41 +0,0 @@
import { Link } from 'react-router-dom';
export function TutorialFinishScreen() {
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">
<div className="text-center">
<img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" />
<h1 className="text-2xl font-light">
Yo, you&apos;ve understood basic features 🎉
</h1>
</div>
<div className="flex flex-col gap-2">
<Link
to="/"
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 Lume
</Link>
<Link
to="https://nostr.how/"
target="_blank"
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"
>
Learn more about Nostr
</Link>
<p className="text-center text-sm font-medium text-neutral-500 dark:text-neutral-600">
If you&apos;ve trouble when user Lume, you can report the issue{' '}
<a
href="github.com/luminous-devs/lume"
target="_blank"
className="text-blue-500 !underline"
>
here
</a>
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,87 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
import { TextNote } from '@libs/ark';
import { EditIcon, ReactionIcon, ReplyIcon, RepostIcon, ZapIcon } from '@shared/icons';
export function TutorialNoteScreen() {
const exampleEvent = new NDKEvent(undefined, {
id: 'a3527670dd9b178bf7c2a9ea673b63bc8bfe774942b196691145343623c45821',
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9',
created_at: 1701355223,
kind: 1,
tags: [],
content: 'good morning nostr, stay humble and stack sats 🫡',
sig: '9e0bd67ec25598744f20bff0fe360fdf190c4240edb9eea260e50f77e07f94ea767ececcc6270819b7f64e5e7ca1fe20b4971f46dc120e6db43114557f3a6dae',
});
return (
<div className="flex h-full w-full select-text items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="flex flex-col items-center gap-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<EditIcon className="h-5 w-5" />
</div>
<h1 className="text-2xl font-light">
What is a <span className="font-bold">Note?</span>
</h1>
</div>
<div className="flex flex-col gap-2">
<p className="px-3">
Posts on Nostr based Social Network client are usually called
&apos;Notes.&apos; Notes are arranged chronologically on the timeline and are
updated in real-time.
</p>
<p className="px-3 font-semibold">Here is one example:</p>
<TextNote event={exampleEvent} />
<p className="px-3 font-semibold">Here are how you can interact with a note:</p>
<div className="flex flex-col gap-2 px-3">
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<ReplyIcon className="h-5 w-5 text-blue-500" />
</div>
<p>
Reply - Click on this button to reply to a note. It&apos;s also possible
to reply to replies, continuing the conversation like a thread.
</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<ReactionIcon className="h-5 w-5 text-red-500" />
</div>
<p>Reaction - You can add reactions to the Note to express your concern.</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<RepostIcon className="h-5 w-5 text-teal-500" />
</div>
<p>
Repost - You can share that note to your own timeline. You can also quote
them with your comments.
</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<ZapIcon className="h-5 w-5 text-orange-500" />
</div>
<p>Zap - You can send tip in Bitcoin to that note owner with zero-fees</p>
</div>
</div>
<div className="mt-5 flex gap-2 px-3">
<Link
to="/auth/finish"
className="inline-flex h-9 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"
>
Back
</Link>
<Link
to="/auth/tutorials/widget"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Continue
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,3 +0,0 @@
export function TutorialPostingScreen() {
return <div></div>;
}

View File

@@ -1,63 +0,0 @@
import { Link } from 'react-router-dom';
import { BellIcon, HomeIcon, PlusIcon } from '@shared/icons';
export function TutorialWidgetScreen() {
return (
<div className="flex h-full w-full select-text items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="flex flex-col items-center gap-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<HomeIcon className="h-5 w-5" />
</div>
<h1 className="text-2xl font-light">
The concept of <span className="font-bold">Widgets</span>
</h1>
</div>
<div className="flex flex-col gap-2 px-3">
<p>
Lume provides multiple widgets based on usage. You always can control what you
need to show on your Home.
</p>
<p className="font-semibold">Default widgets:</p>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<HomeIcon className="h-5 w-5" />
</div>
<p>Newsfeed - You can view notes from accounts you follow.</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<BellIcon className="h-5 w-5" />
</div>
<p>Notification - You can view all notifications related to your account.</p>
</div>
<p>
If you want to add more widget, you can click to this button on Home Screen.
</p>
<div className="flex h-24 w-full items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<button
type="button"
className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
<div className="mt-5 flex gap-2">
<Link
to="/auth/tutorials/note"
className="inline-flex h-9 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"
>
Back
</Link>
<Link
to="/auth/tutorials/finish"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Continue
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,30 +0,0 @@
import { Link } from 'react-router-dom';
export function WelcomeScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-xs flex-col gap-10">
<div className="text-center">
<img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" />
<h1 className="text-2xl">
Welcome to <span className="font-bold">Lume</span>
</h1>
</div>
<div className="flex flex-col gap-2 px-8">
<Link
to="/auth/create"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Create new account
</Link>
<Link
to="/auth/import"
className="inline-flex h-11 w-full items-center justify-center rounded-lg font-medium text-neutral-900 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-900"
>
Log in
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,68 +0,0 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useState } from 'react';
import { toast } from 'sonner';
import { useArk, useStorage } from '@libs/ark';
import { LoaderIcon, RunIcon } from '@shared/icons';
import { User } from '@shared/user';
export function DepotContactCard() {
const ark = useArk();
const storage = useStorage();
const [status, setStatus] = useState(false);
const backupContact = async () => {
try {
setStatus(true);
const event = await ark.getEventByFilter({
filter: { authors: [storage.account.pubkey], kinds: [NDKKind.Contacts] },
});
// broadcast to depot
const publish = await event.publish();
if (publish) {
setStatus(false);
toast.success('Backup contact list successfully.');
}
} catch (e) {
setStatus(false);
toast.error(e);
}
};
return (
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
<div className="isolate flex -space-x-2">
{storage.account.contacts
?.slice(0, 8)
.map((item) => <User key={item} pubkey={item} variant="ministacked" />)}
{storage.account.contacts?.length > 8 ? (
<div className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-300 text-neutral-900 ring-1 ring-white dark:bg-neutral-700 dark:text-neutral-100 dark:ring-black">
<span className="text-[8px] font-medium">
+{storage.account.contacts?.length - 8}
</span>
</div>
) : null}
</div>
</div>
<div className="inline-flex shrink-0 items-center justify-between">
<div className="text-sm font-medium">Contacts</div>
<button
type="button"
onClick={backupContact}
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
>
{status ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<RunIcon className="size-4" />
)}
Backup
</button>
</div>
</div>
);
}

View File

@@ -1,144 +0,0 @@
import * as Dialog from '@radix-ui/react-dialog';
import { resolveResource } from '@tauri-apps/api/path';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import { nip19 } from 'nostr-tools';
import { useEffect, useState } from 'react';
import { parse, stringify } from 'smol-toml';
import { toast } from 'sonner';
import { CancelIcon, PlusIcon, UserAddIcon, UserRemoveIcon } from '@shared/icons';
import { User } from '@shared/user';
export function DepotMembers() {
const [members, setMembers] = useState<Set<string>>(null);
const [tmpMembers, setTmpMembers] = useState<Array<string>>([]);
const [newMember, setNewMember] = useState('');
const addMember = async () => {
if (!newMember.startsWith('npub1'))
return toast.error('You need to enter a valid npub');
try {
const pubkey = nip19.decode(newMember).data as string;
setTmpMembers((prev) => [...prev, pubkey]);
} catch (e) {
console.error(e);
}
};
const removeMember = (member: string) => {
setTmpMembers((prev) => prev.filter((item) => item !== member));
};
const updateMembers = async () => {
setMembers(new Set(tmpMembers));
const defaultConfig = await resolveResource('resources/config.toml');
const config = await readTextFile(defaultConfig);
const configContent = parse(config);
configContent.authorization['pubkey_whitelist'] = [...members];
const newConfig = stringify(configContent);
return await writeTextFile(defaultConfig, newConfig);
};
useEffect(() => {
async function loadConfig() {
const defaultConfig = await resolveResource('resources/config.toml');
const config = await readTextFile(defaultConfig);
const configContent = parse(config);
setTmpMembers(Array.from(configContent.authorization['pubkey_whitelist']));
}
loadConfig();
}, []);
return (
<Dialog.Root>
<div className="flex items-center justify-between rounded-lg bg-neutral-50 p-5 dark:bg-neutral-950">
<div className="flex flex-col items-start">
<h3 className="text-lg font-semibold">Members</h3>
<p className="text-neutral-700 dark:text-neutral-300">
Only allowed users can publish event to your Depot
</p>
</div>
<div className="inline-flex items-center gap-2">
<div className="isolate flex -space-x-2">
{tmpMembers.slice(0, 5).map((item) => (
<User key={item} pubkey={item} variant="stacked" />
))}
{tmpMembers.length > 5 ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-700">
<span className="text-xs font-medium">+{tmpMembers.length}</span>
</div>
) : null}
</div>
<Dialog.Trigger className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-lg bg-blue-500 px-3 text-white hover:bg-blue-600">
<UserAddIcon className="size-4" />
Manage
</Dialog.Trigger>
</div>
</div>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-xl overflow-hidden rounded-xl bg-white dark:bg-black">
<div className="inline-flex h-14 w-full shrink-0 items-center justify-between border-b border-neutral-100 px-5 dark:border-neutral-900">
<Dialog.Title className="text-center font-semibold">
Manage member
</Dialog.Title>
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={updateMembers}
className="inline-flex h-8 w-max items-center justify-center rounded-lg bg-blue-500 px-2.5 text-sm font-medium text-white hover:bg-blue-600"
>
Update
</button>
<Dialog.Close className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800">
<CancelIcon className="size-4" />
</Dialog.Close>
</div>
</div>
<div className="pb-3">
<div className="relative mb-2 mt-4 w-full px-5">
<input
type="text"
spellCheck={false}
value={newMember}
onChange={(e) => setNewMember(e.target.value)}
placeholder="npub1..."
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 pl-3 pr-20 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={addMember}
className="absolute right-7 top-1/2 inline-flex h-7 w-max -translate-y-1/2 transform items-center justify-center gap-1 rounded-md bg-neutral-200 px-2.5 text-sm font-medium text-blue-500 hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
>
<PlusIcon className="size-4" />
Add
</button>
</div>
{tmpMembers.map((member) => (
<div
key={member}
className="group flex items-center justify-between px-5 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<User pubkey={member} variant="simple" />
<button
type="button"
onClick={() => removeMember(member)}
className="hidden size-6 items-center justify-center rounded-md bg-neutral-200 group-hover:inline-flex hover:bg-red-200 dark:bg-neutral-800 dark:hover:bg-red-800 dark:hover:text-red-200"
>
<UserRemoveIcon className="size-4 text-red-500" />
</button>
</div>
))}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,57 +0,0 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useState } from 'react';
import { toast } from 'sonner';
import { useArk, useStorage } from '@libs/ark';
import { LoaderIcon, RunIcon } from '@shared/icons';
import { User } from '@shared/user';
export function DepotProfileCard() {
const ark = useArk();
const storage = useStorage();
const [status, setStatus] = useState(false);
const backupProfile = async () => {
try {
setStatus(true);
const event = await ark.getEventByFilter({
filter: { authors: [storage.account.pubkey], kinds: [NDKKind.Metadata] },
});
// broadcast to depot
const publish = await event.publish();
if (publish) {
setStatus(false);
toast.success('Backup profile successfully.');
}
} catch (e) {
setStatus(false);
toast.error(JSON.stringify(e));
}
};
return (
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
<User pubkey={storage.account.pubkey} variant="simple" />
</div>
<div className="inline-flex shrink-0 items-center justify-between">
<div className="text-sm font-medium">Profile</div>
<button
type="button"
onClick={backupProfile}
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
>
{status ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<RunIcon className="size-4" />
)}
Backup
</button>
</div>
</div>
);
}

View File

@@ -1,68 +0,0 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { useArk, useStorage } from '@libs/ark';
import { LoaderIcon, RunIcon } from '@shared/icons';
export function DepotRelaysCard() {
const ark = useArk();
const storage = useStorage();
const [status, setStatus] = useState(false);
const [relaySize, setRelaySize] = useState(0);
const backupRelays = async () => {
try {
setStatus(true);
const event = await ark.getEventByFilter({
filter: { authors: [storage.account.pubkey], kinds: [NDKKind.RelayList] },
});
// broadcast to depot
const publish = await event.publish();
if (publish) {
setStatus(false);
toast.success('Backup profile successfully.');
}
} catch (e) {
setStatus(false);
toast.error(JSON.stringify(e));
}
};
useEffect(() => {
async function loadRelays() {
const event = await ark.getEventByFilter({
filter: { authors: [storage.account.pubkey], kinds: [NDKKind.RelayList] },
});
if (event) setRelaySize(event.tags.length);
}
loadRelays();
}, []);
return (
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
<p className="text-lg font-semibold">{relaySize} relays</p>
</div>
<div className="inline-flex shrink-0 items-center justify-between">
<div className="text-sm font-medium">Relay List</div>
<button
type="button"
onClick={backupRelays}
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
>
{status ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<RunIcon className="size-4" />
)}
Backup
</button>
</div>
</div>
);
}

View File

@@ -1,216 +0,0 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import * as Collapsible from '@radix-ui/react-collapsible';
import { appConfigDir } from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api/primitives';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { DepotContactCard } from '@app/depot/components/contact';
import { DepotMembers } from '@app/depot/components/members';
import { DepotProfileCard } from '@app/depot/components/profile';
import { DepotRelaysCard } from '@app/depot/components/relays';
import { useArk, useStorage } from '@libs/ark';
import { ChevronDownIcon, DepotIcon, GossipIcon } from '@shared/icons';
export function DepotScreen() {
const ark = useArk();
const storage = useStorage();
const [dataPath, setDataPath] = useState('');
const [tunnelUrl, setTunnelUrl] = useState('');
const openFolder = async () => {
await invoke('show_in_folder', {
path: dataPath + '/nostr.db',
});
};
const updateRelayList = async () => {
try {
if (tunnelUrl.length < 1) return toast.info('Please enter a valid relay url');
if (!tunnelUrl.startsWith('ws'))
return toast.info('Please enter a valid relay url');
const relayUrl = new URL(tunnelUrl.replace(/\s/g, ''));
if (!/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/.test(relayUrl.host)) return;
const relayEvent = await ark.getEventByFilter({
filter: { authors: [storage.account.pubkey], kinds: [NDKKind.RelayList] },
});
let publish: { id: string; seens: string[] };
if (!relayEvent) {
publish = await ark.createEvent({
kind: NDKKind.RelayList,
tags: [['r', tunnelUrl, '']],
});
}
const newTags = relayEvent.tags ?? [];
newTags.push(['r', tunnelUrl, '']);
publish = await ark.createEvent({
kind: NDKKind.RelayList,
tags: newTags,
});
if (publish) {
await storage.createSetting('tunnel_url', tunnelUrl);
toast.success('Update relay list successfully.');
setTunnelUrl('');
}
} catch (e) {
console.error(e);
toast.error('Error');
}
};
useEffect(() => {
async function loadConfig() {
const appDir = await appConfigDir();
setDataPath(appDir);
}
loadConfig();
}, []);
return (
<div className="flex h-full w-full rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
<div className="h-full w-72 shrink-0 rounded-l-xl bg-white/50 px-8 pt-8 backdrop-blur-xl dark:bg-black/50">
<div className="flex flex-col justify-center gap-4">
<div className="size-16 rounded-xl bg-gradient-to-bl from-teal-300 to-teal-600 p-1">
<div className="relative inline-flex h-full w-full items-center justify-center overflow-hidden rounded-lg bg-gradient-to-bl from-teal-400 to-teal-700 shadow-sm shadow-white/20">
<DepotIcon className="size-8 text-white" />
</div>
</div>
<h1 className="text-xl font-semibold">Depot is running</h1>
</div>
<div className="mt-8 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium">Relay URL</div>
<div className="inline-flex h-10 w-full select-text items-center rounded-lg bg-black/10 px-3 text-sm backdrop-blur-xl dark:bg-white/10">
ws://localhost:6090
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium">Database</div>
<div className="inline-flex h-10 w-full items-center gap-2 truncate rounded-lg bg-black/10 p-1 backdrop-blur-xl dark:bg-white/10">
<p className="shrink-0 pl-2 text-sm">nostr.db (SQLite)</p>
<button
type="button"
onClick={openFolder}
className="inline-flex h-full w-full items-center justify-center rounded-md bg-white text-sm font-medium shadow hover:bg-blue-500 hover:text-white dark:bg-black"
>
Open
</button>
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto rounded-r-xl bg-white pb-20 dark:bg-black">
<div className="mb-5 flex h-12 items-center border-b border-neutral-100 px-5 dark:border-neutral-900">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Actions
</h3>
</div>
<div className="flex flex-col gap-5 px-5">
<Collapsible.Root
defaultOpen
className="flex flex-col overflow-hidden rounded-xl border border-transparent bg-neutral-50 data-[state=open]:border-blue-500 dark:bg-neutral-950"
>
<Collapsible.Trigger className="flex h-20 items-center justify-between px-5 hover:bg-neutral-100 dark:hover:bg-neutral-900">
<div className="flex flex-col items-start">
<h3 className="text-lg font-semibold">Expose</h3>
<p className="text-neutral-700 dark:text-neutral-300">
Make your Depot visible in the Internet, everyone can connect into it.
</p>
</div>
<ChevronDownIcon className="size-5 shrink-0" />
</Collapsible.Trigger>
<Collapsible.Content>
<div className="flex w-full flex-col gap-4 p-5">
<div>
<p className="mb-1 font-medium">ngrok</p>
<input
readOnly
value="ngrok http --domain=<your_domain> 6090"
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div>
<p className="mb-1 font-medium">Cloudflare Tunnel</p>
<input
readOnly
value="cloudflared tunnel --url localhost:6090"
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div>
<p className="mb-1 font-medium">Local Tunnel</p>
<input
readOnly
value="lt --port 6090"
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="mt-4 border-t border-neutral-100 pt-4 dark:border-neutral-900">
<div className="inline-flex items-center gap-2">
<GossipIcon className="size-5 text-blue-500" />
<h3 className="mb-1 font-semibold">
Support Gossip Model (Recommended)
</h3>
</div>
<div className="w-full max-w-xl">
<p className=" text-balance">
By adding to Relay List, other Nostr Client which support Gossip
Model will automatically connect to your Depot and improve the
discoverability.
</p>
<div className="mt-2 inline-flex w-full items-center gap-2">
<input
type="text"
value={tunnelUrl}
onChange={(e) => setTunnelUrl(e.target.value)}
spellCheck={false}
placeholder="wss://"
className="h-10 flex-1 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={updateRelayList}
className="inline-flex h-10 w-max shrink-0 items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
>
Update
</button>
</div>
</div>
</div>
</div>
</Collapsible.Content>
</Collapsible.Root>
<Collapsible.Root className="flex flex-col overflow-hidden rounded-xl border border-transparent bg-neutral-50 data-[state=open]:border-blue-500 dark:bg-neutral-950">
<Collapsible.Trigger className="flex h-20 items-center justify-between px-5 hover:bg-neutral-100 dark:hover:bg-neutral-900">
<div className="flex flex-col items-start">
<h3 className="text-lg font-semibold">Backup (Recommended)</h3>
<p className="text-neutral-700 dark:text-neutral-300">
Backup all your data to Depot, it always live on your machine.
</p>
</div>
<ChevronDownIcon className="size-5 shrink-0" />
</Collapsible.Trigger>
<Collapsible.Content>
<div className="grid grid-cols-3 gap-4 px-5 py-5">
<DepotProfileCard />
<DepotContactCard />
<DepotRelaysCard />
</div>
</Collapsible.Content>
</Collapsible.Root>
<DepotMembers />
</div>
</div>
</div>
);
}

View File

@@ -1,96 +0,0 @@
import { resolveResource } from '@tauri-apps/api/path';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { parse, stringify } from 'smol-toml';
import { toast } from 'sonner';
import { useArk, useStorage } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { delay } from '@utils/delay';
export function DepotOnboardingScreen() {
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const launchDepot = async () => {
try {
setLoading(true);
// get default config
const defaultConfig = await resolveResource('resources/config.toml');
const config = await readTextFile(defaultConfig);
const parsedConfig = parse(config);
// add current user to whitelist
parsedConfig.authorization['pubkey_whitelist'].push(storage.account.pubkey);
// update new config
const newConfig = stringify(parsedConfig);
await writeTextFile(defaultConfig, newConfig);
// launch depot
await storage.launchDepot();
await storage.createSetting('depot', '1');
await delay(2000); // delay 2s to make sure depot is running
// default depot url: ws://localhost:6090
// #TODO: user can custom depot url
const connect = await ark.connectDepot();
if (connect) {
toast.success('Your Depot is successfully launch.');
setLoading(false);
navigate('/depot/');
}
} catch (e) {
toast.error(e);
}
};
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-10 rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
<div className="flex flex-col items-center gap-8">
<div className="text-center">
<h1 className="mb-1 text-3xl font-semibold text-neutral-400 dark:text-neutral-600">
Run your Personal Nostr Relay inside Lume
</h1>
<h2 className="text-4xl font-semibold">Your Relay, Your Control.</h2>
</div>
<div className="rounded-xl bg-blue-100 p-1.5 dark:bg-blue-900">
<button
type="button"
onClick={launchDepot}
className="inline-flex h-11 w-36 transform items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white active:translate-y-1"
>
{loading ? (
<>
<LoaderIcon className="h-5 w-5 animate-spin" />
Launching...
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
>
<path
fillRule="evenodd"
d="M12 2.25a.75.75 0 0 1 .75.75v9a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM6.166 5.106a.75.75 0 0 1 0 1.06 8.25 8.25 0 1 0 11.668 0 .75.75 0 1 1 1.06-1.06c3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788a.75.75 0 0 1 1.06 0Z"
clipRule="evenodd"
/>
</svg>
Launch
</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,154 +0,0 @@
import { downloadDir } from '@tauri-apps/api/path';
import { message, save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';
import { relaunch } from '@tauri-apps/plugin-process';
import { useRouteError } from 'react-router-dom';
import { useStorage } from '@libs/ark';
interface RouteError {
statusText: string;
message: string;
}
export function ErrorScreen() {
const storage = useStorage();
const error = useRouteError() as RouteError;
const restart = async () => {
await relaunch();
};
const download = async () => {
try {
const downloadPath = await downloadDir();
const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
const filePath = await save({
defaultPath: downloadPath + '/' + fileName,
});
const nsec = await storage.loadPrivkey(storage.account.pubkey);
if (filePath) {
if (nsec) {
await writeTextFile(
filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${storage.account.id}\nPrivate key: ${nsec}`
);
} else {
await writeTextFile(
filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${storage.account.id}`
);
}
} // else { user cancel action }
} catch (e) {
await message(e, { title: 'Cannot download account keys', type: 'error' });
}
};
return (
<div
data-tauri-drag-region
className="relative flex h-screen w-screen items-center justify-center bg-blue-600"
>
<div className="flex w-full max-w-2xl flex-col items-start gap-8">
<div className="flex flex-col">
<h1 className="mb-3 text-4xl font-semibold text-blue-400">
Sorry, an unexpected error has occurred.
</h1>
<h3 className="text-3xl font-semibold leading-snug text-white">
Don&apos;t panic, your account is safe.
<br />
Here are what things you can do:
</h3>
</div>
<div className="flex w-full flex-col gap-3">
<div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
<div className="text-xl font-semibold text-white">
1. Try to close and re-open the app
</div>
<button
type="button"
onClick={() => restart()}
className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
>
Restart
</button>
</div>
<div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
<div className="text-xl font-semibold text-white">
2. Backup Nostr account
</div>
<button
type="button"
onClick={() => download()}
className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
>
Download
</button>
</div>
<div className="rounded-xl bg-blue-700 px-3 py-4">
<div className="flex w-full flex-col gap-2">
<div className="flex w-full items-center justify-between">
<div className="text-xl font-semibold text-white">
3. Report this issue to Lume&apos;s Devs
</div>
<a
href="https://github.com/luminous-devs/lume/issues/new"
target="_blank"
rel="noreferrer"
className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
>
Report
</a>
</div>
<div className="inline-flex h-16 items-center justify-center overflow-y-auto rounded-lg border border-dashed border-red-300 bg-blue-800 px-5">
<p className="select-text break-all text-red-400">
{error.statusText || error.message}
</p>
</div>
</div>
</div>
<div className="rounded-xl bg-blue-700 px-3 py-4">
<div className="flex w-full flex-col gap-1.5">
<div className="text-xl font-semibold text-white">
4. Use another Nostr client
</div>
<div className="select-text text-lg font-medium text-blue-300">
<p>
While waiting for Lume&apos;s Devs to release the bug fixes, you always
can use other Nostr clients with your account:
</p>
<div className="mt-2 flex flex-col gap-1 text-white">
<a
className="hover:!underline"
href="https://snort.social"
target="_blank"
rel="noreferrer"
>
snort.social
</a>
<a
className="hover:!underline"
href="https://primal.net"
target="_blank"
rel="noreferrer"
>
primal.net
</a>
<a
className="hover:!underline"
href="https://nostrudel.ninja"
target="_blank"
rel="noreferrer"
>
nostrudel.ninja
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -1,104 +0,0 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo, useRef } from 'react';
import { VList, VListHandle } from 'virtua';
import { RepostNote, TextNote, Widget, useArk, useStorage } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon, TimelineIcon } from '@shared/icons';
import { FETCH_LIMIT } from '@utils/constants';
export function NewsfeedWidget() {
const ark = useArk();
const storage = useStorage();
const ref = useRef<VListHandle>();
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['newsfeed'],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: !storage.account.contacts.length
? [storage.account.pubkey]
: storage.account.contacts,
},
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 = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<Widget.Root>
<Widget.Header
id="9999"
queryKey={['newsfeed']}
title="Timeline"
icon={<TimelineIcon className="h-5 w-5" />}
/>
<Widget.Content>
<VList ref={ref} overscan={2} className="flex-1">
{isLoading ? (
<div className="inline-flex h-16 items-center justify-center gap-2 px-3 py-1.5">
<LoaderIcon className="size-5" />
Loading
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 py-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-5 w-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@@ -1,159 +0,0 @@
import { NDKEvent, NDKKind, NDKSubscription } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { VList } from 'virtua';
import { NoteSkeleton, TextNote, Widget, useArk, useStorage } from '@libs/ark';
import { AnnouncementIcon, ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { FETCH_LIMIT } from '@utils/constants';
import { sendNativeNotification } from '@utils/notification';
export function NotificationWidget() {
const ark = useArk();
const storage = useStorage();
const queryClient = useQueryClient();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['notification'],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
'#p': [storage.account.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,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data]
);
const renderEvent = (event: NDKEvent) => {
if (event.pubkey === storage.account.pubkey) return null;
return <TextNote key={event.id} event={event} />;
};
useEffect(() => {
let sub: NDKSubscription = undefined;
if (status === 'success' && storage.account) {
const filter = {
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
'#p': [storage.account.pubkey],
since: Math.floor(Date.now() / 1000),
};
sub = ark.subscribe({
filter,
closeOnEose: false,
cb: async (event) => {
queryClient.setQueryData(
['notification'],
(prev: { pageParams: number; pages: Array<NDKEvent[]> }) => ({
...prev,
pages: [[event], ...prev.pages],
})
);
const profile = await ark.getUserProfile({ pubkey: event.pubkey });
switch (event.kind) {
case NDKKind.Text:
return await sendNativeNotification(
`${profile.displayName || profile.name} has replied to your note`
);
case NDKKind.Repost:
return await sendNativeNotification(
`${profile.displayName || profile.name} has reposted to your note`
);
case NDKKind.Reaction:
return await sendNativeNotification(
`${profile.displayName || profile.name} has reacted ${
event.content
} to your note`
);
case NDKKind.Zap:
return await sendNativeNotification(
`${profile.displayName || profile.name} has zapped to your note`
);
default:
break;
}
},
});
}
return () => {
if (sub) sub.stop();
};
}, [status]);
return (
<Widget.Root>
<Widget.Header
id="9998"
queryKey={['notification']}
title="Notification"
icon={<AnnouncementIcon className="h-5 w-5" />}
/>
<Widget.Content>
<VList className="flex-1" overscan={2}>
{status === 'pending' ? (
<NoteSkeleton />
) : allEvents.length < 1 ? (
<div className="my-3 flex w-full items-center justify-center gap-2">
<div>🎉</div>
<p className="text-center font-medium text-neutral-900 dark:text-neutral-100">
Hmm! Nothing new yet.
</p>
</div>
) : (
allEvents.map((event) => renderEvent(event))
)}
<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>
</Widget.Content>
</Widget.Root>
);
}

View File

@@ -1,99 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { VList, VListHandle } from 'virtua';
import { NewsfeedWidget, NotificationWidget } from '@app/home/components';
import { useStorage } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { WIDGET_KIND } from '@utils/constants';
import { WidgetProps } from '@utils/types';
export function HomeScreen() {
const storage = useStorage();
const ref = useRef<VListHandle>(null);
const { isLoading, data } = useQuery({
queryKey: ['widgets'],
queryFn: async () => {
const dbWidgets = await storage.getWidgets();
const defaultWidgets = [
{
id: '9999',
title: 'Newsfeed',
content: '',
kind: WIDGET_KIND.newsfeed,
},
];
return [...defaultWidgets, ...dbWidgets];
},
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
});
const [selectedIndex, setSelectedIndex] = useState(-1);
const renderItem = (widget: WidgetProps) => {
switch (widget.kind) {
case WIDGET_KIND.notification:
return <NotificationWidget key={widget.id} />;
case WIDGET_KIND.newsfeed:
return <NewsfeedWidget key={widget.id} />;
default:
return <NewsfeedWidget key={widget.id} />;
}
};
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
);
}
return (
<div className="h-full w-full">
<VList
ref={ref}
className="h-full w-full flex-nowrap overflow-x-auto !overflow-y-hidden scrollbar-none focus:outline-none"
initialItemSize={420}
tabIndex={0}
horizontal
onKeyDown={(e) => {
if (!ref.current) return;
switch (e.code) {
case 'ArrowUp':
case 'ArrowLeft': {
e.preventDefault();
const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex);
ref.current.scrollToIndex(prevIndex, {
align: 'center',
smooth: true,
});
break;
}
case 'ArrowDown':
case 'ArrowRight': {
e.preventDefault();
const nextIndex = Math.min(selectedIndex + 1, data.length - 1);
setSelectedIndex(nextIndex);
ref.current.scrollToIndex(nextIndex, {
align: 'center',
smooth: true,
});
break;
}
default:
break;
}
}}
>
{data.map((widget) => renderItem(widget))}
<div className="h-full w-[200px]" />
</VList>
</div>
);
}

View File

@@ -1,285 +0,0 @@
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';
import { EditorContent, FloatingMenu, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge';
import { Markdown } from 'tiptap-markdown';
import { ArticleCoverUploader, MediaUploader, MentionPopup } from '@app/new/components';
import { useArk } from '@libs/ark';
import {
BoldIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ItalicIcon,
LoaderIcon,
ThreadsIcon,
} from '@shared/icons';
export function NewArticleScreen() {
const ark = useArk();
const [height, setHeight] = useState(0);
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState('');
const [summary, setSummary] = useState({ open: false, content: '' });
const [cover, setCover] = useState('');
const navigate = useNavigate();
const containerRef = useRef(null);
const ident = useMemo(() => String(Date.now()), []);
const editor = useEditor({
extensions: [
StarterKit.configure(),
Placeholder.configure({ placeholder: 'Type something...' }),
Image.configure({
HTMLAttributes: {
class:
'rounded-lg w-full object-cover h-auto max-h-[400px] border border-neutral-200 dark:border-neutral-800 outline outline-1 outline-offset-0 outline-neutral-300 dark:outline-neutral-700',
},
}),
CharacterCount.configure(),
Markdown.configure({
html: false,
tightLists: true,
linkify: true,
transformPastedText: true,
}),
],
content: JSON.parse(localStorage.getItem('editor-post') || '{}'),
editorProps: {
attributes: {
class:
'outline-none prose prose-lg prose-neutral max-w-none select-text whitespace-pre-line break-words dark:prose-invert hover:prose-a:text-blue-500',
},
},
onUpdate: ({ editor }) => {
const jsonContent = JSON.stringify(editor.getJSON());
localStorage.setItem('editor-article', jsonContent);
},
});
const submit = async () => {
try {
if (!ark.ndk.signer) return navigate('/new/privkey');
setLoading(true);
// get markdown content
const content = editor.storage.markdown.getMarkdown();
// define tags
const tags: NDKTag[] = [
['d', ident],
['title', title],
['image', cover],
['summary', summary.content],
['published_at', String(Math.floor(Date.now() / 1000))],
];
// add hashtag to tags if present
const hashtags = content.split(/\s/gm).filter((s: string) => s.startsWith('#'));
hashtags?.forEach((tag: string) => {
tags.push(['t', tag.replace('#', '')]);
});
// publish
const publish = await ark.createEvent({
content,
tags,
kind: NDKKind.Article,
});
if (publish) {
toast.success(`Broadcasted to ${publish.seens.length} relays successfully.`);
// update state
setLoading(false);
// reset editor
editor.commands.clearContent();
localStorage.setItem('editor-article', '{}');
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
useLayoutEffect(() => {
setHeight(containerRef.current.clientHeight);
}, []);
return (
<div className="flex flex-1 flex-col justify-between">
<div className="flex-1 overflow-y-auto">
<div
className="flex flex-col gap-4"
ref={containerRef}
style={{ height: `${height}px` }}
>
{cover ? (
<img
src={cover}
alt="post cover"
className="h-72 w-full rounded-lg object-cover"
/>
) : null}
<div className="group flex justify-between gap-2">
<input
name="title"
className="h-9 flex-1 border-none bg-transparent px-0 text-2xl font-semibold text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 focus:border-none focus:outline-none focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="Untitled"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<div
className={twMerge(
'inline-flex shrink-0 gap-2 group-hover:inline-flex',
title.length > 0 ? '' : 'hidden'
)}
>
<ArticleCoverUploader setCover={setCover} />
<button
type="button"
onClick={() => setSummary((prev) => ({ ...prev, open: !prev.open }))}
className="inline-flex h-9 w-max items-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
>
<ThreadsIcon className="h-4 w-4" />
Add summary
</button>
</div>
</div>
{summary.open ? (
<div className="flex gap-3">
<div className="h-16 w-1 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" />
<div className="flex-1">
<textarea
className="h-16 w-full border-none bg-transparent px-1 py-1 text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="A brief summary of your article"
value={summary.content}
onChange={(e) =>
setSummary((prev) => ({ ...prev, content: e.target.value }))
}
/>
</div>
</div>
) : null}
<div>
{editor && (
<FloatingMenu
editor={editor}
tippyOptions={{ duration: 100 }}
className="ml-36 inline-flex h-10 items-center gap-1 rounded-lg border border-neutral-200 bg-neutral-100 px-px dark:border-neutral-800 dark:bg-neutral-900"
>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 1 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading1Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 2 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading2Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 3 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading3Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('bold') ? 'bg-white shadow dark:bg-black' : ''
)}
>
<BoldIcon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('italic') ? 'bg-white shadow dark:bg-black' : ''
)}
>
<ItalicIcon className="h-5 w-5" />
</button>
</FloatingMenu>
)}
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
</div>
</div>
</div>
<div>
<div className="mb-3 flex h-12 w-full items-center rounded-lg bg-yellow-100 px-3 text-yellow-700">
<p className="text-sm">
Article editor is still in beta. If you need a stable and more reliable
feature, you can use <b>Habla (habla.news)</b> instead.
</p>
</div>
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
<div className="inline-flex items-center gap-3">
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.characters()} characters
</span>
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
-
</span>
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
<b>Identifier:</b>
{ident}
</span>
</div>
<div className="flex items-center">
<div className="inline-flex items-center gap-2">
<MediaUploader editor={editor} />
<MentionPopup editor={editor} />
</div>
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
<button
onClick={() => submit()}
disabled={editor && editor.isEmpty}
className="inline-flex h-9 w-max items-center justify-center rounded-lg bg-blue-500 px-2.5 font-medium text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading === true ? (
<LoaderIcon className="h-5 w-5 animate-spin" />
) : (
'Publish article'
)}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,83 +0,0 @@
import { message, open } from '@tauri-apps/plugin-dialog';
import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { useState } from 'react';
import { ImageIcon, LoaderIcon } from '@shared/icons';
export function ArticleCoverUploader({ setCover }) {
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => {
try {
// start loading
setLoading(true);
const selected = await open({
multiple: false,
filters: [
{
name: 'Media',
extensions: [
'png',
'jpeg',
'jpg',
'gif',
'mp4',
'mp3',
'webm',
'mkv',
'avi',
'mov',
],
},
],
});
if (!selected) {
setLoading(false);
return;
}
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) {
const json = await res.json();
const content = json.data[0];
setCover(content.url);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
return (
<button
type="button"
onClick={uploadToNostrBuild}
className="inline-flex h-9 w-max items-center justify-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ImageIcon className="h-4 w-4" />
Add cover
</>
)}
</button>
);
}

View File

@@ -1,5 +0,0 @@
export * from './articleCoverUploader';
export * from './mediaUploader';
export * from './mentionPopup';
export * from './mentionPopupItem';
export * from './mentionList';

View File

@@ -1,43 +0,0 @@
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';
export function MediaUploader({ editor }: { editor: Editor }) {
const ark = useArk();
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => {
try {
// start loading
setLoading(true);
const image = await ark.upload({
fileExts: ['mp4', 'mp3', 'webm', 'mkv', 'avi', 'mov'],
});
if (image) {
editor.commands.setImage({ src: image });
editor.commands.createParagraphNear();
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
return (
<button
type="button"
onClick={() => uploadToNostrBuild()}
className="inline-flex h-9 w-max items-center justify-center gap-1.5 rounded-lg bg-neutral-100 px-2 text-sm font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<MediaIcon className="h-5 w-5" />
{loading ? 'Uploading...' : 'Add media'}
</button>
);
}

View File

@@ -1,103 +0,0 @@
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { Ref, forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { NDKCacheUserProfile } from '@utils/types';
type MentionListRef = {
onKeyDown: (props: { event: Event }) => boolean;
};
const List = (
props: {
items: NDKCacheUserProfile[];
command: (arg0: { id: string }) => void;
},
ref: Ref<unknown>
) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const item = props.items[index];
if (item) {
props.command({ id: item.pubkey });
}
};
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
downHandler();
return true;
}
if (event.key === 'Enter') {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="flex w-[200px] flex-col overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 shadow-lg shadow-neutral-500/20 dark:border-neutral-800 dark:bg-neutral-950 dark:shadow-neutral-300/50">
{props.items.length ? (
props.items.map((item, index) => (
<button
key={index}
onClick={() => selectItem(index)}
className={twMerge(
'inline-flex h-11 items-center gap-2 rounded-md px-2',
index === selectedIndex ? 'bg-neutral-100 dark:bg-neutral-900' : ''
)}
>
<Avatar.Root className="h-8 w-8 shrink-0">
<Avatar.Image
src={item.image}
alt={item.name}
loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md"
/>
<Avatar.Fallback delayMs={150}>
<img
src={
'data:image/svg+xml;utf8,' +
encodeURIComponent(minidenticon(item.name, 90, 50))
}
alt={item.name}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<h5 className="max-w-[150px] truncate text-sm font-medium">{item.name}</h5>
</button>
))
) : (
<div className="text-center text-sm font-medium">No result</div>
)}
</div>
);
};
export const MentionList = forwardRef<MentionListRef>(List);

View File

@@ -1,47 +0,0 @@
import * as Popover from '@radix-ui/react-popover';
import { Editor } from '@tiptap/react';
import { nip19 } from 'nostr-tools';
import { MentionPopupItem } from '@app/new/components';
import { useStorage } from '@libs/ark';
import { MentionIcon } from '@shared/icons';
export function MentionPopup({ editor }: { editor: Editor }) {
const storage = useStorage();
const insertMention = (pubkey: string) => {
editor.commands.insertContent(`nostr:${nip19.npubEncode(pubkey)}`);
};
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-max items-center justify-center gap-1.5 rounded-lg bg-neutral-100 px-2 text-sm font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<MentionIcon className="h-5 w-5" />
Mention
</button>
</Popover.Trigger>
<Popover.Content
side="top"
sideOffset={5}
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">
{storage.account.contacts.length ? (
storage.account.contacts.map((item) => (
<button key={item} type="button" onClick={() => insertMention(item)}>
<MentionPopupItem pubkey={item} />
</button>
))
) : (
<div className="flex h-16 items-center justify-center">
Contact list is empty
</div>
)}
</div>
</Popover.Content>
</Popover.Root>
);
}

View File

@@ -1,54 +0,0 @@
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useMemo } from 'react';
import { displayNpub } from '@utils/formater';
import { useProfile } from '@utils/hooks/useProfile';
export function MentionPopupItem({ pubkey, embed }: { pubkey: string; embed?: string }) {
const { isLoading, user } = useProfile(pubkey, embed);
const svgURI = useMemo(
() => 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50)),
[pubkey]
);
if (isLoading) {
return (
<div className="flex items-center gap-2.5 px-2">
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="h-4 w-1/2 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
<span className="h-3 w-1/3 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
</div>
</div>
);
}
return (
<div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-neutral-200 dark:bg-neutral-800">
<Avatar.Root className="shirnk-0 h-8 w-8">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={pubkey}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-col items-start gap-px">
<h5 className="max-w-[10rem] truncate text-sm font-medium leading-none text-neutral-900 dark:text-neutral-100">
{user?.display_name || user?.displayName || user?.name}
</h5>
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
{displayNpub(pubkey, 16)}
</span>
</div>
</div>
);
}

View File

@@ -1,175 +0,0 @@
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 { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
export function NewFileScreen() {
const ark = useArk();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [isPublish, setIsPublish] = useState(false);
const [metadata, setMetadata] = useState<string[][] | null>(null);
const [caption, setCaption] = useState('');
const uploadFile = async () => {
try {
setLoading(true);
const selected = await open({
multiple: false,
filters: [
{
name: 'Media',
extensions: [
'png',
'jpeg',
'jpg',
'gif',
'mp4',
'mp3',
'webm',
'mkv',
'avi',
'mov',
],
},
],
});
if (!selected) {
setLoading(false);
return;
}
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) {
const json = await res.json();
const data = json.data[0];
setMetadata([
['url', data.url],
['m', data.mime ?? 'application/octet-stream'],
['x', data.sha256 ?? ''],
['size', data.size.toString() ?? '0'],
['dim', `${data.dimensions.width}x${data.dimensions.height}` ?? '0'],
['blurhash', data.blurhash ?? ''],
['thumb', data.thumbnail ?? ''],
]);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
const submit = async () => {
try {
if (!ark.ndk.signer) return navigate('/new/privkey');
setIsPublish(true);
const publish = await ark.createEvent({
kind: 1063,
tags: metadata,
content: caption,
});
if (publish) {
toast.success(`Broadcasted to ${publish.seens.length} relays successfully.`);
setMetadata(null);
setIsPublish(false);
}
} catch (e) {
setIsPublish(false);
toast.error(e);
}
};
return (
<div className="h-full">
<div className="flex h-96 gap-4 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<button
type="button"
onClick={uploadFile}
className="flex h-full flex-1 flex-col items-center justify-center rounded-lg border border-dashed border-neutral-200 bg-neutral-50 p-2 hover:border-blue-500 hover:text-blue-500 dark:border-neutral-800 dark:bg-neutral-950"
>
{loading ? (
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
) : !metadata ? (
<div className="flex flex-col text-center">
<h5 className="text-lg font-semibold">
Click or drag a file to this area to upload
</h5>
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
Supports: jpg, png, webp, gif, mov, mp4 or mp3
</p>
</div>
) : (
<div>
<img
src={metadata[0][1]}
alt={metadata[1][1]}
className="aspect-square h-full w-full rounded-lg object-cover shadow-lg"
/>
</div>
)}
</button>
{metadata ? (
<div className="flex h-full flex-1 flex-col justify-between">
<div className="flex flex-col gap-2 py-2">
{metadata.map((item, index) => (
<div key={index} className="flex min-w-0 gap-2">
<h5 className="w-24 shrink-0 truncate font-semibold capitalize text-neutral-600 dark:text-neutral-400">
{item[0]}
</h5>
<p className="w-72 truncate">{item[1]}</p>
</div>
))}
</div>
<div className="flex flex-col gap-2">
<input
name="caption"
type="text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="Caption (Optional)..."
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-900 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={submit}
disabled={!metadata}
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 disabled:opacity-50"
>
{isPublish ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Share'}
</button>
</div>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -1,167 +0,0 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import CharacterCount from '@tiptap/extension-character-count';
import Image from '@tiptap/extension-image';
import Mention from '@tiptap/extension-mention';
import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { convert } from 'html-to-text';
import { nip19 } from 'nostr-tools';
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 { MentionNote, useArk, useWidget } from '@libs/ark';
import { CancelIcon, LoaderIcon } from '@shared/icons';
import { WIDGET_KIND } from '@utils/constants';
import { useSuggestion } from '@utils/hooks/useSuggestion';
export function NewPostScreen() {
const ark = useArk();
const { addWidget } = useWidget();
const { suggestion } = useSuggestion();
const [loading, setLoading] = useState(false);
const [height, setHeight] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const containerRef = useRef(null);
const editor = useEditor({
extensions: [
StarterKit.configure(),
Placeholder.configure({ placeholder: 'Sharing some thoughts...' }),
Image.configure({
HTMLAttributes: {
class:
'rounded-lg w-full object-cover h-auto max-h-[400px] border border-neutral-200 dark:border-neutral-800 outline outline-1 outline-offset-0 outline-neutral-300 dark:outline-neutral-700',
},
}),
CharacterCount.configure(),
Mention.configure({
suggestion,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
renderLabel({ options, node }) {
const npub = nip19.npubEncode(node.attrs.id);
return `nostr:${npub}`;
},
}),
],
content: JSON.parse(localStorage.getItem('editor-post') || '{}'),
editorProps: {
attributes: {
class:
'outline-none prose prose-lg prose-neutral max-w-none select-text whitespace-pre-line break-words dark:prose-invert hover:prose-a:text-blue-500',
},
},
onUpdate: ({ editor }) => {
const jsonContent = JSON.stringify(editor.getJSON());
localStorage.setItem('editor-post', jsonContent);
},
});
const submit = async () => {
try {
if (!ark.ndk.signer) return navigate('/new/privkey');
setLoading(true);
// get plaintext content
const html = editor.getHTML();
const serializedContent = convert(html, {
selectors: [
{ selector: 'a', options: { linkBrackets: false } },
{ selector: 'img', options: { linkBrackets: false } },
],
});
// add reply to tags if present
const replyTo = searchParams.get('replyTo');
const rootReplyTo = searchParams.get('rootReplyTo');
// publish event
const publish = await ark.createEvent({
kind: NDKKind.Text,
tags: [],
content: serializedContent,
replyTo,
rootReplyTo,
});
if (publish) {
toast.success(`Broadcasted to ${publish.seens.length} relays successfully.`);
// update state
setLoading(false);
setSearchParams({});
// open new widget with this event id
if (!replyTo) {
addWidget.mutate({
title: 'Thread',
content: publish.id,
kind: WIDGET_KIND.thread,
});
}
// reset editor
editor.commands.clearContent();
localStorage.setItem('editor-post', '{}');
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
useLayoutEffect(() => {
setHeight(containerRef.current.clientHeight);
}, []);
return (
<div className="flex h-[500px] flex-1 flex-col gap-4">
<div className="flex-1 overflow-y-auto">
<div ref={containerRef} style={{ height: `${height}px` }}>
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
{searchParams.get('replyTo') && (
<div className="relative max-w-lg">
<MentionNote eventId={searchParams.get('replyTo')} />
<button
type="button"
onClick={() => setSearchParams({})}
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded bg-neutral-200 px-2 dark:bg-neutral-800"
>
<CancelIcon className="h-5 w-5" />
</button>
</div>
)}
</div>
</div>
<div className="inline-flex h-16 w-full items-center justify-between border-t border-neutral-100 bg-neutral-50 dark:border-neutral-900 dark:bg-neutral-950">
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.characters()} characters
</span>
<div className="flex items-center">
<div className="inline-flex items-center gap-2">
<MediaUploader editor={editor} />
<MentionPopup editor={editor} />
</div>
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
<button
onClick={() => submit()}
disabled={editor && editor.isEmpty}
className="inline-flex h-9 w-20 items-center justify-center rounded-lg bg-blue-500 px-2 font-medium text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading === true ? <LoaderIcon className="h-5 w-5 animate-spin" /> : 'Post'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,80 +0,0 @@
import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { getPublicKey, nip19 } from 'nostr-tools';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useArk, useStorage } from '@libs/ark';
export function NewPrivkeyScreen() {
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const [nsec, setNsec] = useState('');
const submit = async (isSave?: boolean) => {
try {
if (!nsec.startsWith('nsec1'))
return toast.info('You must enter a private key starts with nsec');
const decoded = nip19.decode(nsec);
if (decoded.type !== 'nsec') return toast.info('You must enter a valid nsec');
const privkey = decoded.data;
const pubkey = getPublicKey(privkey);
if (pubkey !== storage.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);
ark.updateNostrSigner({ signer });
if (isSave) await storage.createPrivkey(storage.account.pubkey, privkey);
navigate(-1);
} catch (e) {
toast.error(e);
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mb-16 flex flex-col gap-3">
<h1 className="text-center font-semibold text-neutral-900 dark:text-neutral-100">
You need to provide private key to sign nostr event.
</h1>
<input
name="privkey"
placeholder="nsec..."
type="password"
value={nsec}
onChange={(e) => setNsec(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<div className="mt-2 flex flex-col gap-2">
<button
type="button"
onClick={() => submit()}
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-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>
</div>
</div>
</div>
);
}

View File

@@ -1,62 +0,0 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { useStorage } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
export function NWCForm({ setWalletConnectURL }) {
const storage = useStorage();
const [uri, setUri] = useState('');
const [loading, setLoading] = useState(false);
const submit = async () => {
try {
setLoading(true);
if (!uri.startsWith('nostr+walletconnect:')) {
toast.error(
'Connect URI is required and must start with format nostr+walletconnect:, please check again'
);
setLoading(false);
return;
}
const uriObj = new URL(uri);
const params = new URLSearchParams(uriObj.search);
if (params.has('relay') && params.has('secret')) {
await storage.createPrivkey(`${storage.account.pubkey}-nwc`, uri);
setWalletConnectURL(uri);
setLoading(false);
} else {
setLoading(false);
toast.error('Connect URI is not valid, please check again');
return;
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="flex flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<textarea
name="walletConnectURL"
value={uri}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={false}
onChange={(e) => setUri(e.target.value)}
placeholder="nostr+walletconnect://"
className="h-40 w-full resize-none rounded-lg border-transparent bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={submit}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Connect'}
</button>
</div>
);
}

View File

@@ -1,122 +0,0 @@
import { useEffect, useState } from 'react';
import { NWCForm } from '@app/nwc/components/form';
import { useStorage } from '@libs/ark';
import { CheckCircleIcon } from '@shared/icons';
export function NWCScreen() {
const storage = useStorage();
const [walletConnectURL, setWalletConnectURL] = useState<null | string>(null);
const remove = async () => {
await storage.removePrivkey(`${storage.account.pubkey}-nwc`);
setWalletConnectURL(null);
};
useEffect(() => {
async function getNWC() {
const nwc = await storage.loadPrivkey(`${storage.account.pubkey}-nwc`);
if (nwc) setWalletConnectURL(nwc);
}
getNWC();
}, []);
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex w-full flex-col gap-5">
<div className="text-center">
<h3 className="text-2xl font-bold leading-tight">Nostr Wallet Connect</h3>
<p className="leading-tight text-neutral-600 dark:text-neutral-400">
Sending zap easily via Bitcoin Lightning.
</p>
</div>
<div className="mx-auto max-w-lg">
{!walletConnectURL ? (
<NWCForm setWalletConnectURL={setWalletConnectURL} />
) : (
<div className="flex w-full flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<div className="flex items-center justify-center gap-1.5 text-sm text-teal-500">
<CheckCircleIcon className="h-4 w-4" />
<div>You&apos;re using nostr wallet connect</div>
</div>
<div className="flex flex-col gap-3">
<textarea
readOnly
value={walletConnectURL.substring(0, 120) + '****'}
className="h-40 w-full resize-none rounded-lg border-transparent bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => remove()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-neutral-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:bg-neutral-800 dark:text-neutral-100"
>
Remove connection
</button>
</div>
</div>
)}
<div className="mt-5 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
Introduction
</h5>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Nostr Wallet Connect (NWC) is a way for applications like Nostr clients to
access a remote Lightning wallet through a standardized protocol.
</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
To learn more about the details have a look at{' '}
<a
href="https://github.com/nostr-protocol/nips/blob/master/47.md"
target="_blank"
className="text-blue-500"
rel="noreferrer"
>
the specs (NIP47)
</a>
</p>
</div>
<div className="flex flex-col gap-1.5">
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
About zapping
</h5>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Lume doesn&apos;t take any commission or platform fees when you zap
someone.
</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Lume doesn&apos;t hold your Bitcoin
</p>
</div>
<div className="flex flex-col gap-1.5">
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
Recommend wallet that support NWC
</h5>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Mutiny Wallet:{' '}
<a
href="https://www.mutinywallet.com/"
target="_blank"
rel="noreferrer"
className="text-blue-500"
>
website
</a>
</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Self hosted NWC on Umbrel :{' '}
<a
href="https://apps.umbrel.com/app/alby-nostr-wallet-connect"
target="_blank"
rel="noreferrer"
className="text-blue-500"
>
website
</a>
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,90 +0,0 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { VList } from 'virtua';
import { NoteSkeleton, RepostNote, TextNote, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { FETCH_LIMIT } from '@utils/constants';
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
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) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
},
[data]
);
return (
<VList className="mx-auto h-full w-full max-w-[500px] pt-10 scrollbar-none">
{status === 'pending' ? (
<NoteSkeleton />
) : (
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>
</VList>
);
}

View File

@@ -1,60 +0,0 @@
import { NDKRelayUrl } from '@nostr-dev-kit/ndk';
import { normalizeRelayUrl } from 'nostr-fetch';
import { useState } from 'react';
import { toast } from 'sonner';
import { PlusIcon } from '@shared/icons';
import { useRelay } from '@utils/hooks/useRelay';
const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/;
export function RelayForm() {
const { connectRelay } = useRelay();
const [relay, setRelay] = useState<{
url: NDKRelayUrl;
purpose: 'read' | 'write' | undefined;
}>({ url: '', purpose: undefined });
const create = () => {
if (relay.url.length < 1) return toast.info('Please enter relay url');
try {
const relayUrl = new URL(relay.url.replace(/\s/g, ''));
if (
domainRegex.test(relayUrl.host) &&
(relayUrl.protocol === 'wss:' || relayUrl.protocol === 'ws:')
) {
connectRelay.mutate(normalizeRelayUrl(relay.url));
setRelay({ url: '', purpose: undefined });
} else {
return toast.error(
'URL is invalid, a relay must use websocket protocol (start with wss:// or ws://). Please check again'
);
}
} catch {
return toast.error('Relay URL is not valid. Please check again');
}
};
return (
<div className="flex flex-col gap-1">
<div className="flex gap-2">
<input
className="h-11 flex-1 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
placeholder="wss://"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
value={relay.url}
onChange={(e) => setRelay((prev) => ({ ...prev, url: e.target.value }))}
/>
<button
type="button"
onClick={() => create()}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-blue-500 text-white hover:bg-blue-600"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
);
}

View File

@@ -1,92 +0,0 @@
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 { useRelay } from '@utils/hooks/useRelay';
export function RelayList() {
const ark = useArk();
const { connectRelay } = useRelay();
const { status, data } = useQuery({
queryKey: ['relays'],
queryFn: async () => {
return await ark.getAllRelaysFromContacts();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
const navigate = useNavigate();
const inspectRelay = (relayUrl: string) => {
const url = new URL(relayUrl);
navigate(`/relays/${url.hostname}`);
};
return (
<div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center pb-10">
<div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
<p>Loading relay...</p>
</div>
</div>
) : (
<VList className="h-full">
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
<h3 className="font-semibold">Relay discovery</h3>
</div>
{[...data].map(([key, value]) => (
<div
key={key}
className="flex h-14 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900"
>
<div className="inline-flex items-center gap-2 divide-x divide-neutral-100 dark:divide-neutral-900">
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={() => inspectRelay(key)}
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-neutral-200 px-1.5 text-sm font-medium text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
<ShareIcon className="h-3 w-3" />
Inspect
</button>
<button
type="button"
onClick={() => connectRelay.mutate(key)}
className="inline-flex h-6 w-6 items-center justify-center rounded text-neutral-900 hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<PlusIcon className="h-3 w-3" />
</button>
</div>
<div className="inline-flex items-center gap-2 pl-3">
<span className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">
Relay:{' '}
</span>
<span className="max-w-[200px] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{key}
</span>
</div>
</div>
<div className="isolate flex -space-x-2">
{value.slice(0, 4).map((item) => (
<User key={item} pubkey={item} variant="stacked" />
))}
{value.length > 4 ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-700">
<span className="text-xs font-medium">+{value.length}</span>
</div>
) : null}
</div>
</div>
))}
</VList>
)}
</div>
);
}

View File

@@ -1,93 +0,0 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { RelayForm } from '@app/relays/components/relayForm';
import { useArk, useStorage } from '@libs/ark';
import { CancelIcon, RefreshIcon } from '@shared/icons';
import { useRelay } from '@utils/hooks/useRelay';
export function UserRelayList() {
const ark = useArk();
const storage = useStorage();
const { removeRelay } = useRelay();
const { status, data, refetch } = useQuery({
queryKey: ['relays', storage.account.pubkey],
queryFn: async () => {
const event = await ark.getEventByFilter({
filter: {
kinds: [NDKKind.RelayList],
authors: [storage.account.pubkey],
},
});
if (!event) return [];
return event.tags;
},
refetchOnWindowFocus: false,
});
const currentRelays = new Set(ark.ndk.pool.connectedRelays().map((item) => item.url));
return (
<div className="col-span-1">
<div className="inline-flex h-16 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
<h3 className="font-semibold">Connected relays</h3>
<button
type="button"
onClick={() => refetch()}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<RefreshIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex flex-col gap-2 px-3">
{status === 'pending' ? (
<p>Loading...</p>
) : !data.length ? (
<div className="flex h-20 w-full items-center justify-center rounded-xl bg-neutral-50 dark:bg-neutral-950">
<p className="text-sm font-medium">You not have personal relay list yet</p>
</div>
) : (
data.map((item) => (
<div
key={item[1]}
className="group flex h-11 items-center justify-between rounded-lg bg-neutral-100 px-3 dark:bg-neutral-900"
>
<div className="inline-flex items-baseline gap-2">
{currentRelays.has(item[1]) ? (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-teal-500"></span>
</span>
) : (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</span>
)}
<p className="max-w-[20rem] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{item[1]}
</p>
</div>
<div className="inline-flex items-center gap-2">
{item[2]?.length ? (
<div className="inline-flex h-6 w-max items-center justify-center rounded bg-neutral-200 px-2 text-xs font-medium capitalize dark:bg-neutral-800">
{item[2]}
</div>
) : null}
<button
type="button"
onClick={() => removeRelay.mutate(item[1])}
className="hidden h-6 w-6 items-center justify-center rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<CancelIcon className="h-4 w-4 text-neutral-900 dark:text-neutral-100" />
</button>
</div>
</div>
))
)}
<RelayForm />
</div>
</div>
);
}

View File

@@ -1,11 +0,0 @@
import { RelayList } from '@app/relays/components/relayList';
import { UserRelayList } from '@app/relays/components/userRelayList';
export function RelaysScreen() {
return (
<div className="grid h-full w-full grid-cols-3">
<RelayList />
<UserRelayList />
</div>
);
}

View File

@@ -1,176 +0,0 @@
import { Suspense } from 'react';
import { Await, useLoaderData, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { NIP11 } from '@utils/types';
import { RelayEventList } from './components/relayEventList';
export function RelayScreen() {
const { url } = useParams();
const data: { relay?: { [key: string]: string } } = useLoaderData();
const navigate = useNavigate();
const getSoftwareName = (url: string) => {
const filename = url.substring(url.lastIndexOf('/') + 1);
return filename.replace('.git', '');
};
const titleCase = (s: string) => {
return s
.replace(/^[-_]*(.)/, (_, c) => c.toUpperCase())
.replace(/[-_]+(.)/g, (_, c) => ' ' + c.toUpperCase());
};
return (
<div className="grid h-full w-full grid-cols-3">
<div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900">
<div className="inline-flex h-16 w-full items-center gap-2.5 border-b border-neutral-100 px-3 dark:border-neutral-900">
<button type="button" onClick={() => navigate(-1)}>
<ArrowLeftIcon className="h-5 w-5 text-neutral-500 hover:text-neutral-600 dark:text-neutral-600 dark:hover:text-neutral-500" />
</button>
<h3 className="font-semibold text-neutral-950 dark:text-neutral-50">
Global events
</h3>
</div>
<RelayEventList relayUrl={url} />
</div>
<div className="col-span-1">
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">
Information
</h3>
</div>
<div className="mt-4 px-3">
<Suspense
fallback={
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900 dark:text-neutral-100">
<LoaderIcon className="h-4 w-4 animate-spin" />
Loading...
</div>
}
>
<Await
resolve={data.relay}
errorElement={
<div className="text-sm font-medium">
<p>Could not load relay information 😬</p>
</div>
}
>
{(resolvedRelay: NIP11) => (
<div className="flex flex-col gap-5">
<div>
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
{resolvedRelay.name}
</h3>
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-500">
{resolvedRelay.description}
</p>
</div>
{resolvedRelay.pubkey ? (
<div className="flex flex-col gap-1">
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Owner:
</h5>
<div className="w-full rounded-lg bg-neutral-100 px-2 py-2 dark:bg-neutral-900">
<User pubkey={resolvedRelay.pubkey} variant="simple" />
</div>
</div>
) : null}
{resolvedRelay.contact ? (
<div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Contact:
</h5>
<a
href={`mailto:${resolvedRelay.contact}`}
target="_blank"
className="underline after:content-['_↗'] hover:text-blue-600"
rel="noreferrer"
>
mailto:{resolvedRelay.contact}
</a>
</div>
) : null}
<div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Software:
</h5>
<a
href={resolvedRelay.software}
target="_blank"
rel="noreferrer"
className="underline after:content-['_↗'] hover:text-blue-600"
>
{getSoftwareName(resolvedRelay.software) +
' - ' +
resolvedRelay.version}
</a>
</div>
<div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Supported NIPs:
</h5>
<div className="mt-2 grid grid-cols-7 gap-2">
{resolvedRelay.supported_nips.map((item) => (
<a
key={item}
href={`https://nips.be/${item}`}
target="_blank"
rel="noreferrer"
className="inline-flex aspect-square h-auto w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
>
{item}
</a>
))}
</div>
</div>
{resolvedRelay.limitation ? (
<div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Limitation
</h5>
<div className="flex flex-col gap-2 divide-y divide-white/5">
{Object.keys(resolvedRelay.limitation).map((key, index) => {
return (
<div
key={index}
className="flex items-baseline justify-between pt-2"
>
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{titleCase(key)}:
</p>
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
{resolvedRelay.limitation[key].toString()}
</p>
</div>
);
})}
</div>
</div>
) : null}
{resolvedRelay.payments_url ? (
<div className="flex flex-col gap-1">
<a
href={resolvedRelay.payments_url}
target="_blank"
rel="noreferrer"
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium hover:bg-blue-600"
>
Open payment website
</a>
<span className="text-center text-xs text-neutral-600 dark:text-neutral-400">
You need to make a payment to connect this relay
</span>
</div>
) : null}
</div>
)}
</Await>
</Suspense>
</div>
</div>
</div>
);
}

View File

@@ -1,76 +0,0 @@
import { getVersion } from '@tauri-apps/api/app';
import { relaunch } from '@tauri-apps/plugin-process';
import { Update, check } from '@tauri-apps/plugin-updater';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
export function AboutScreen() {
const [version, setVersion] = useState('');
const [newUpdate, setNewUpdate] = useState<Update>(null);
const checkUpdate = async () => {
const update = await check();
if (!update) toast.info('There is no update available');
setNewUpdate(update);
};
const installUpdate = async () => {
await newUpdate.downloadAndInstall();
await relaunch();
};
useEffect(() => {
async function loadVersion() {
const appVersion = await getVersion();
setVersion(appVersion);
}
loadVersion();
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex items-center justify-center gap-2">
<img src="/icon.png" alt="Lume's logo" className="w-16 shrink-0" />
<div>
<h1 className="text-xl font-semibold">Lume</h1>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Version {version}
</p>
</div>
</div>
<div className="mx-auto mt-4 flex w-full max-w-xs flex-col gap-2">
{!newUpdate ? (
<button
type="button"
onClick={() => checkUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
>
Check for update
</button>
) : (
<button
type="button"
onClick={() => installUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
>
Install {newUpdate.version}
</button>
)}
<Link
to="https://lume.nu"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Website
</Link>
<Link
to="https://github.com/luminous-devs/lume/issues"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Report a issue
</Link>
</div>
</div>
);
}

View File

@@ -1,29 +0,0 @@
import { useArk } from '@libs/ark';
export function AdvancedSettingScreen() {
const ark = useArk();
const clearCache = async () => {
await ark.clearCache();
};
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex flex-col gap-6">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Caches</div>
<div className="text-sm">Use for boost up NDK</div>
</div>
<button
type="button"
onClick={() => clearCache()}
className="h-8 w-max rounded-lg bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
>
Clear
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,62 +0,0 @@
import { nip19 } from 'nostr-tools';
import { useEffect, useState } from 'react';
import { useStorage } from '@libs/ark';
import { EyeOffIcon } from '@shared/icons';
export function BackupSettingScreen() {
const storage = useStorage();
const [privkey, setPrivkey] = useState(null);
const [showPassword, setShowPassword] = useState(false);
const removePrivkey = async () => {
await storage.removePrivkey(storage.account.pubkey);
};
useEffect(() => {
async function loadPrivkey() {
const key = await storage.loadPrivkey(storage.account.pubkey);
if (key) setPrivkey(key);
}
loadPrivkey();
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div className="mb-2 text-sm font-semibold">Private key</div>
<div>
{!privkey ? (
<div className="inline-flex h-24 w-full items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
You&apos;ve stored private key on Lume
</div>
) : (
<>
<div className="relative">
<input
readOnly
type={showPassword ? 'text' : 'password'}
value={nip19.nsecEncode(privkey)}
className="relative h-11 w-full resize-none rounded-lg border-none bg-neutral-200 py-1 pl-3 pr-11 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-1.5 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 transform items-center justify-center rounded-lg bg-neutral-50 dark:bg-neutral-950"
>
<EyeOffIcon className="h-4 w-4" />
</button>
</div>
<button
type="button"
onClick={() => removePrivkey()}
className="mt-2 inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-red-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:hover:text-white"
>
Remove private key
</button>
</>
)}
</div>
</div>
);
}

View File

@@ -1,45 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { useArk } from '@libs/ark';
import { EditIcon, LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/formater';
export function ContactCard() {
const ark = useArk();
const { status, data } = useQuery({
queryKey: ['contacts'],
queryFn: async () => {
const contacts = await ark.getUserContacts({});
return contacts;
},
refetchOnWindowFocus: false,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<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.length)}
</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">
Contacts
</p>
<Link
to="/settings/edit-contact"
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"
>
<EditIcon className="h-3 w-3" />
Edit
</Link>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,58 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { Link } from 'react-router-dom';
import { useStorage } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/formater';
export function PostCard() {
const storage = useStorage();
const { status, data } = useQuery({
queryKey: ['user-stats', storage.account.pubkey],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${storage.account.pubkey}`,
{
signal,
}
);
if (!res.ok) {
throw new Error('Error');
}
return await res.json();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<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[storage.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/${storage.account.pubkey}`}
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
View
</Link>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,78 +0,0 @@
import * as Avatar from '@radix-ui/react-avatar';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { minidenticon } from 'minidenticons';
import { nip19 } from 'nostr-tools';
import { Link } from 'react-router-dom';
import { useStorage } from '@libs/ark';
import { EditIcon, LoaderIcon } from '@shared/icons';
import { displayNpub } from '@utils/formater';
import { useProfile } from '@utils/hooks/useProfile';
export function ProfileCard() {
const storage = useStorage();
const svgURI =
'data:image/svg+xml;utf8,' +
encodeURIComponent(minidenticon(storage.account.pubkey, 90, 50));
const { isLoading, user } = useProfile(storage.account.pubkey);
const copyNpub = async () => {
return await writeText(nip19.npubEncode(storage.account.pubkey));
};
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">
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<div className="flex h-10 w-full justify-end gap-3">
<button
type="button"
onClick={copyNpub}
className="inline-flex h-8 w-28 transform items-center justify-center gap-1.5 rounded-full bg-neutral-200 text-sm font-medium hover:bg-neutral-400 active:translate-y-1 dark:bg-neutral-800 dark:hover:bg-neutral-600"
>
Copy NPUB
</button>
<Link
to="/settings/edit-profile"
className="inline-flex h-8 w-20 items-center justify-center gap-1.5 rounded-full bg-neutral-200 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-600"
>
<EditIcon className="h-4 w-4" />
Edit
</Link>
</div>
<div className="flex flex-col gap-2.5">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={storage.account.pubkey}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-16 w-16 rounded-xl border border-neutral-200/50 shadow-[rgba(17,_17,_26,_0.1)_0px_0px_16px] dark:border-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={storage.account.pubkey}
className="h-16 w-16 rounded-xl bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div>
<h3 className="text-3xl font-semibold leading-8 text-neutral-900 dark:text-neutral-100">
{user?.display_name || user?.name}
</h3>
<p className="text-lg text-neutral-700 dark:text-neutral-300">
{user?.nip05 || displayNpub(storage.account.pubkey, 16)}
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,47 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { useArk, useStorage } from '@libs/ark';
import { EditIcon, LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/formater';
export function RelayCard() {
const ark = useArk();
const storage = useStorage();
const { status, data } = useQuery({
queryKey: ['relays', storage.account.pubkey],
queryFn: async () => {
const relays = await ark.getUserRelays({});
return relays;
},
refetchOnWindowFocus: false,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<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?.relays?.length || 0)}
</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">
Relays
</p>
<Link
to="/relays"
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"
>
<EditIcon className="h-3 w-3" />
Edit
</Link>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,51 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { useStorage } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/formater';
export function ZapCard() {
const storage = useStorage();
const { status, data } = useQuery({
queryKey: ['user-stats', storage.account.pubkey],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${storage.account.pubkey}`,
{
signal,
}
);
if (!res.ok) {
throw new Error('Error');
}
return await res.json();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<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[storage.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">
Sats received
</div>
</div>
)}
</div>
);
}

View File

@@ -1,34 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
export function EditContactScreen() {
const ark = useArk();
const { status, data } = useQuery({
queryKey: ['contacts'],
queryFn: async () => {
return await ark.getUserContacts({});
},
refetchOnWindowFocus: false,
});
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-3">
{status === 'pending' ? (
<div className="flex h-10 w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
data.map((item) => (
<div
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} variant="simple" />
</div>
))
)}
</div>
);
}

View File

@@ -1,295 +0,0 @@
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 { useArk, useStorage } from '@libs/ark';
import { CheckCircleIcon, LoaderIcon, PlusIcon, UnverifiedIcon } from '@shared/icons';
export function EditProfileScreen() {
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState('');
const [banner, setBanner] = useState('');
const [nip05, setNIP05] = useState({ verified: true, text: '' });
const ark = useArk();
const storage = useStorage();
const {
register,
handleSubmit,
reset,
setError,
formState: { isValid, errors },
} = useForm({
defaultValues: async () => {
const res: NDKUserProfile = queryClient.getQueryData([
'user',
storage.account.pubkey,
]);
if (res.image) {
setPicture(res.image);
}
if (res.banner) {
setBanner(res.banner);
}
if (res.nip05) {
setNIP05((prev) => ({ ...prev, text: res.nip05 }));
}
return res;
},
});
const queryClient = useQueryClient();
const navigate = useNavigate();
const uploadAvatar = async () => {
try {
if (!ark.ndk.signer) return navigate('/new/privkey');
setLoading(true);
const image = await ark.upload({});
if (image) {
setPicture(image);
setLoading(false);
}
} catch (e) {
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
const uploadBanner = async () => {
try {
setLoading(true);
const image = await ark.upload({});
if (image) {
setBanner(image);
setLoading(false);
}
} catch (e) {
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
const onSubmit = async (data: NDKUserProfile) => {
// start loading
setLoading(true);
let content = {
...data,
username: data.name,
display_name: data.name,
bio: data.about,
image: data.picture,
};
if (data.nip05) {
const verify = ark.validateNIP05({
pubkey: storage.account.pubkey,
nip05: data.nip05,
});
if (verify) {
content = { ...content, nip05: data.nip05 };
} else {
setNIP05((prev) => ({ ...prev, verified: false }));
setError('nip05', {
type: 'manual',
message: "Can't verify your Lume ID / NIP-05, please check again",
});
}
}
const publish = await ark.createEvent({
kind: NDKKind.Metadata,
tags: [],
content: JSON.stringify(content),
});
if (publish) {
// invalid cache
queryClient.invalidateQueries({
queryKey: ['user', storage.account.pubkey],
});
// reset form
reset();
// reset state
setLoading(false);
setPicture(null);
setBanner(null);
} else {
setLoading(false);
}
};
return (
<div className="mx-auto w-full max-w-md">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
<input type={'hidden'} {...register('picture')} value={picture} />
<input type={'hidden'} {...register('banner')} value={banner} />
<div className="flex flex-col items-center justify-center">
<div className="relative h-36 w-full">
{banner ? (
<img
src={banner}
alt="user's banner"
className="h-full w-full rounded-xl object-cover"
/>
) : (
<div className="h-full w-full rounded-xl bg-neutral-200 dark:bg-neutral-900" />
)}
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform overflow-hidden rounded-xl">
<button
type="button"
onClick={() => uploadBanner()}
className="inline-flex h-full w-full items-center justify-center bg-black/20 text-white"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="mb-5 px-4">
<div className="relative z-10 -mt-7 h-14 w-14 overflow-hidden rounded-xl ring-2 ring-white dark:ring-black">
<img
src={picture}
alt="user's avatar"
className="h-14 w-14 rounded-xl object-cover"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<button
type="button"
onClick={() => uploadAvatar()}
className="inline-flex h-full w-full items-center justify-center rounded-xl bg-black/50 text-white"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label
htmlFor="display_name"
className="text-sm font-semibold uppercase tracking-wider"
>
Display Name
</label>
<input
type={'text'}
{...register('display_name')}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider"
>
Name
</label>
<input
type={'text'}
{...register('name')}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="nip05"
className="text-sm font-semibold uppercase tracking-wider"
>
NIP-05
</label>
<div className="relative">
<input
{...register('nip05')}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 transform">
{nip05.verified ? (
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-teal-500 px-1 pr-1.5 text-xs font-medium text-white">
<CheckCircleIcon className="h-4 w-4" />
Verified
</span>
) : (
<span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 pl-1 pr-1.5 text-xs font-medium text-white">
<UnverifiedIcon className="h-4 w-4" />
Unverified
</span>
)}
</div>
{errors.nip05 && (
<p className="mt-1 text-sm text-red-400">
{errors.nip05.message.toString()}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider"
>
Website
</label>
<input
type={'text'}
{...register('website', { required: false })}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider"
>
Lightning address
</label>
<input
type={'text'}
{...register('lud16', { required: false })}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider"
>
Bio
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div>
<button
type="submit"
disabled={!isValid}
className="mx-auto inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
) : (
'Update'
)}
</button>
</div>
</div>
</form>
</div>
);
}

View File

@@ -1,270 +0,0 @@
import * as Switch from '@radix-ui/react-switch';
import { invoke } from '@tauri-apps/api/primitives';
import { getCurrent } from '@tauri-apps/api/window';
import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart';
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
import { useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { useStorage } from '@libs/ark';
import { DarkIcon, LightIcon, SystemModeIcon } from '@shared/icons';
export function GeneralSettingScreen() {
const storage = useStorage();
const [settings, setSettings] = useState({
autoupdate: false,
autolaunch: false,
outbox: false,
media: true,
hashtag: true,
notification: true,
appearance: 'system',
});
const changeTheme = async (theme: 'light' | 'dark' | 'auto') => {
await invoke('plugin:theme|set_theme', { theme });
// update state
setSettings((prev) => ({ ...prev, appearance: theme }));
};
const toggleAutolaunch = async () => {
if (!settings.autolaunch) {
await enable();
// update state
setSettings((prev) => ({ ...prev, autolaunch: true }));
} else {
await disable();
// update state
setSettings((prev) => ({ ...prev, autolaunch: false }));
}
};
const toggleOutbox = async () => {
await storage.createSetting('outbox', String(+!settings.outbox));
// update state
setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
};
const toggleMedia = async () => {
await storage.createSetting('media', String(+!settings.media));
storage.settings.media = !settings.media;
// update state
setSettings((prev) => ({ ...prev, media: !settings.media }));
};
const toggleHashtag = async () => {
await storage.createSetting('hashtag', String(+!settings.hashtag));
storage.settings.hashtag = !settings.hashtag;
// update state
setSettings((prev) => ({ ...prev, hashtag: !settings.hashtag }));
};
const toggleAutoupdate = async () => {
await storage.createSetting('autoupdate', String(+!settings.autoupdate));
storage.settings.autoupdate = !settings.autoupdate;
// update state
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
};
const toggleNofitication = async () => {
if (settings.notification) return;
await requestPermission();
// update state
setSettings((prev) => ({ ...prev, notification: !settings.notification }));
};
useEffect(() => {
async function loadSettings() {
const theme = await getCurrent().theme();
setSettings((prev) => ({ ...prev, appearance: theme }));
const autostart = await isEnabled();
setSettings((prev) => ({ ...prev, autolaunch: autostart }));
const permissionGranted = await isPermissionGranted();
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
const data = await storage.getAllSettings();
if (!data) return;
data.forEach((item) => {
if (item.key === 'autoupdate')
setSettings((prev) => ({
...prev,
autoupdate: !!parseInt(item.value),
}));
if (item.key === 'outbox')
setSettings((prev) => ({
...prev,
outbox: !!parseInt(item.value),
}));
if (item.key === 'media')
setSettings((prev) => ({
...prev,
media: !!parseInt(item.value),
}));
if (item.key === 'hashtag')
setSettings((prev) => ({
...prev,
hashtag: !!parseInt(item.value),
}));
});
}
loadSettings();
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex flex-col gap-6">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Updater</div>
<div className="text-sm">Auto download new update at Login</div>
</div>
<Switch.Root
checked={settings.autoupdate}
onClick={() => toggleAutoupdate()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Startup</div>
<div className="text-sm">Launch Lume at Login</div>
</div>
<Switch.Root
checked={settings.autolaunch}
onClick={() => toggleAutolaunch()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Gossip</div>
<div className="text-sm">Use Outbox model</div>
</div>
<Switch.Root
checked={settings.outbox}
onClick={() => toggleOutbox()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Media</div>
<div className="text-sm">Automatically load media</div>
</div>
<Switch.Root
checked={settings.media}
onClick={() => toggleMedia()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Hashtag</div>
<div className="text-sm">Hide all hashtags in content</div>
</div>
<Switch.Root
checked={settings.hashtag}
onClick={() => toggleHashtag()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">
Notification
</div>
<div className="text-sm">Automatically send notification</div>
</div>
<Switch.Root
checked={settings.notification}
disabled={settings.notification}
onClick={() => toggleNofitication()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Appearance</div>
<div className="flex flex-1 gap-6">
<button
type="button"
onClick={() => changeTheme('light')}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={twMerge(
'inline-flex h-11 w-11 items-center justify-center rounded-lg',
settings.appearance === 'light'
? 'bg-blue-500 text-white'
: 'bg-neutral-100 dark:bg-neutral-900'
)}
>
<LightIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Light
</p>
</button>
<button
type="button"
onClick={() => changeTheme('dark')}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={twMerge(
'inline-flex h-11 w-11 items-center justify-center rounded-lg',
settings.appearance === 'dark'
? 'bg-blue-500 text-white'
: 'bg-neutral-100 dark:bg-neutral-900'
)}
>
<DarkIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Dark
</p>
</button>
<button
type="button"
onClick={() => changeTheme('auto')}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={twMerge(
'inline-flex h-11 w-11 items-center justify-center rounded-lg',
settings.appearance === 'auto'
? 'bg-blue-500 text-white'
: 'bg-neutral-100 dark:bg-neutral-900'
)}
>
<SystemModeIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
System
</p>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,19 +0,0 @@
import { ContactCard } from '@app/settings/components/contactCard';
import { PostCard } from '@app/settings/components/postCard';
import { ProfileCard } from '@app/settings/components/profileCard';
import { RelayCard } from '@app/settings/components/relayCard';
import { ZapCard } from '@app/settings/components/zapCard';
export function UserSettingScreen() {
return (
<div className="mx-auto w-full max-w-xl">
<ProfileCard />
<div className="grid grid-cols-2 gap-4">
<ContactCard />
<RelayCard />
<PostCard />
<ZapCard />
</div>
</div>
);
}

View File

@@ -1,149 +0,0 @@
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { UserStats } from '@app/users/components/stats';
import { useArk, useStorage } from '@libs/ark';
import { NIP05 } from '@shared/nip05';
import { displayNpub } from '@utils/formater';
import { useProfile } from '@utils/hooks/useProfile';
export function UserProfile({ pubkey }: { pubkey: string }) {
const ark = useArk();
const storage = useStorage();
const { user } = useProfile(pubkey);
const [followed, setFollowed] = useState(false);
const navigate = useNavigate();
const svgURI =
'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50));
const follow = async () => {
try {
if (!ark.ndk.signer) return navigate('/new/privkey');
setFollowed(true);
const add = await ark.createContact({ pubkey });
if (!add) {
toast.success('You already follow this user');
setFollowed(false);
}
} catch (e) {
toast.error(e);
setFollowed(false);
}
};
const unfollow = async () => {
try {
if (!ark.ndk.signer) return navigate('/new/privkey');
setFollowed(false);
await ark.deleteContact({ pubkey });
} catch (e) {
toast.error(e);
}
};
useEffect(() => {
if (storage.account.contacts.includes(pubkey)) {
setFollowed(true);
}
}, []);
if (!user) return <p>Loading...</p>;
return (
<>
<div className="h-56 w-full overflow-hidden rounded-tl-lg">
{user?.banner ? (
<img
src={user?.banner}
alt="user banner"
className="h-full w-full rounded-tl-lg object-cover"
/>
) : (
<div className="h-full w-full rounded-tl-lg bg-neutral-100 dark:bg-neutral-900" />
)}
</div>
<div className="-mt-7 flex w-full flex-col items-center px-5">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-14 w-14 rounded-lg bg-white object-cover ring-2 ring-neutral-100 dark:ring-neutral-900"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={pubkey}
className="h-14 w-14 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="mt-2 flex flex-1 flex-col gap-6">
<div className="flex flex-col items-center gap-1">
<div className="inline-flex flex-col items-center">
<h5 className="text-center text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName || 'No name'}
</h5>
{user?.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user.nip05}
className="text-neutral-600 dark:text-neutral-400"
/>
) : (
<span className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-400">
{displayNpub(pubkey, 16)}
</span>
)}
</div>
<div className="flex flex-col gap-6">
{user?.about || user?.bio ? (
<p className="mt-2 max-w-[500px] select-text break-words text-center text-neutral-900 dark:text-neutral-100">
{user.about || user.bio}
</p>
) : (
<div />
)}
<UserStats pubkey={pubkey} />
</div>
</div>
<div className="inline-flex items-center justify-center gap-2">
{followed ? (
<button
type="button"
onClick={unfollow}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
>
Unfollow
</button>
) : (
<button
type="button"
onClick={follow}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
>
Follow
</button>
)}
<Link
to={`/chats/${pubkey}`}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
>
Message
</Link>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,74 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/formater';
export function UserStats({ pubkey }: { pubkey: string }) {
const { status, data } = useQuery({
queryKey: ['user-stats', pubkey],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`, {
signal,
});
if (!res.ok) {
throw new Error('Error');
}
return await res.json();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
if (status === 'pending') {
return (
<div className="flex w-full items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
</div>
);
}
if (status === 'error') {
return <div className="flex w-full items-center justify-center" />;
}
return (
<div className="flex w-full items-center justify-center gap-10">
<div className="inline-flex flex-col items-center gap-1">
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data?.stats[pubkey]?.followers_pubkey_count) ?? 0}
</span>
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
Followers
</span>
</div>
<div className="inline-flex flex-col items-center gap-1">
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data?.stats[pubkey]?.pub_following_pubkey_count) ?? 0}
</span>
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
Following
</span>
</div>
<div className="inline-flex flex-col items-center gap-1">
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data?.stats[pubkey]?.zaps_received?.msats / 1000 ?? 0)}
</span>
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
Zaps received
</span>
</div>
<div className="inline-flex flex-col items-center gap-1">
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data?.stats[pubkey]?.zaps_sent?.msats / 1000 ?? 0)}
</span>
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
Zaps sent
</span>
</div>
</div>
);
}

View File

@@ -1,100 +0,0 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
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 { NoteSkeleton, RepostNote, TextNote, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { FETCH_LIMIT } from '@utils/constants';
export function UserScreen() {
const { pubkey } = useParams();
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(
(event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
},
[data]
);
return (
<div className="relative h-full w-full overflow-y-auto">
<UserProfile pubkey={pubkey} />
<div className="mt-6 h-full w-full border-t border-neutral-100 px-1.5 dark:border-neutral-900">
<h3 className="mb-2 pt-4 text-center text-lg font-semibold leading-none text-neutral-900 dark:text-neutral-100">
Latest posts
</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' ? (
<NoteSkeleton />
) : (
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>
);
}

View File

@@ -1,511 +0,0 @@
import NDK, {
NDKEvent,
NDKFilter,
NDKKind,
NDKNip46Signer,
NDKPrivateKeySigner,
NDKRelay,
NDKSubscriptionCacheUsage,
NDKTag,
NDKUser,
NostrEvent,
} 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 { NostrFetcher, normalizeRelayUrl } from 'nostr-fetch';
import { nip19 } from 'nostr-tools';
import { LumeStorage } from '@libs/storage';
import { Account, type NDKEventWithReplies, type NIP05 } from '@utils/types';
export class Ark {
#storage: LumeStorage;
#fetcher: NostrFetcher;
public ndk: NDK;
public account: Account;
constructor({
ndk,
storage,
fetcher,
}: {
ndk: NDK;
storage: LumeStorage;
fetcher: NostrFetcher;
}) {
this.ndk = ndk;
this.#storage = storage;
this.#fetcher = fetcher;
}
public async connectDepot() {
return this.ndk.addExplicitRelay(
new NDKRelay(normalizeRelayUrl('ws://localhost:6090')),
undefined,
true
);
}
public updateNostrSigner({ signer }: { signer: NDKNip46Signer | NDKPrivateKeySigner }) {
this.ndk.signer = signer;
return this.ndk.signer;
}
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 async createEvent({
kind,
tags,
content,
rootReplyTo = undefined,
replyTo = undefined,
}: {
kind: NDKKind | number;
tags: NDKTag[];
content?: string;
rootReplyTo?: string;
replyTo?: string;
}) {
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');
}
const publish = await event.publish();
if (!publish) throw new Error('Failed to publish event');
return { id: event.id, seens: [...publish.values()].map((item) => item.url) };
} catch (e) {
throw new Error(e);
}
}
public async getUserProfile({ pubkey }: { pubkey: string }) {
try {
// get clean pubkey without any special characters
let hexstring = pubkey.replace(/[^a-zA-Z0-9]/g, '');
if (
hexstring.startsWith('npub1') ||
hexstring.startsWith('nprofile1') ||
hexstring.startsWith('naddr1')
) {
const decoded = nip19.decode(hexstring);
if (decoded.type === 'nprofile') hexstring = decoded.data.pubkey;
if (decoded.type === 'npub') hexstring = decoded.data;
if (decoded.type === 'naddr') hexstring = decoded.data.pubkey;
}
const user = this.ndk.getUser({ pubkey: hexstring });
const profile = await user.fetchProfile({
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
});
if (!profile) return null;
return profile;
} catch (e) {
throw new Error(e);
}
}
public async getUserContacts({
pubkey = undefined,
outbox = undefined,
}: {
pubkey?: string;
outbox?: boolean;
}) {
try {
const user = this.ndk.getUser({
pubkey: pubkey ? pubkey : this.#storage.account.pubkey,
});
const contacts = [...(await user.follows(undefined, outbox))].map(
(user) => user.pubkey
);
if (pubkey === this.#storage.account.pubkey)
this.#storage.account.contacts = contacts;
return contacts;
} catch (e) {
throw new Error(e);
}
}
public async getUserRelays({ pubkey }: { pubkey?: string }) {
try {
const user = this.ndk.getUser({
pubkey: pubkey ? pubkey : this.#storage.account.pubkey,
});
return await user.relayList();
} catch (e) {
throw new Error(e);
}
}
public async newContactList({ tags }: { tags: NDKTag[] }) {
const publish = await this.createEvent({
kind: NDKKind.Contacts,
tags: tags,
});
if (publish) {
this.#storage.account.contacts = tags.map((item) => item[1]);
return publish;
}
}
public async createContact({ pubkey }: { pubkey: string }) {
const user = this.ndk.getUser({ pubkey: this.#storage.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.#storage.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 }) {
let eventId: string = id;
if (
eventId.startsWith('nevent1') ||
eventId.startsWith('note1') ||
eventId.startsWith('naddr1')
) {
const decode = nip19.decode(eventId);
if (decode.type === 'nevent') eventId = decode.data.id;
if (decode.type === 'note') eventId = decode.data;
if (decode.type === 'naddr') {
return await this.ndk.fetchEvent({
kinds: [decode.data.kind],
'#d': [decode.data.identifier],
authors: [decode.data.pubkey],
});
}
}
return await this.ndk.fetchEvent(id, {
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
});
}
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 connectedRelays = this.ndk.pool.connectedRelays().map((item) => item.url);
const relayMap = new Map<string, string[]>();
const relayEvents = this.#fetcher.fetchLatestEventsPerAuthor(
{
authors: this.#storage.account.contacts,
relayUrls: connectedRelays,
},
{ 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 connectedRelays = this.ndk.pool.connectedRelays().map((item) => item.url);
const events = await this.#fetcher.fetchLatestEvents(connectedRelays, 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;
}
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) {
throw new Error(e);
}
}
}

View File

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

View File

@@ -1,82 +0,0 @@
import { NDKEvent, NDKKind, NostrEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useArk } from '@libs/ark/provider';
import { Note } from '..';
export function RepostNote({ event }: { event: NDKEvent }) {
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;
return new NDKEvent(ark.ndk, embed);
}
const id = event.tags.find((el) => el[0] === 'e')[1];
return await ark.getEventById({ id });
} catch {
throw new Error('Failed to get repost event');
}
},
refetchOnWindowFocus: false,
});
const renderContentByKind = () => {
if (!repostEvent) return null;
switch (repostEvent.kind) {
case NDKKind.Text:
return <Note.TextContent content={repostEvent.content} />;
case 1063:
return <Note.MediaContent tags={repostEvent.tags} />;
default:
return null;
}
};
if (isLoading) {
return <div className="w-full px-3 pb-3"></div>;
}
if (isError) {
return (
<div className="my-3 h-min w-full px-3">
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
<div className="relative flex flex-col gap-2">
<div className="px-3">
<p>Failed to load event</p>
</div>
</div>
</div>
</div>
);
}
return (
<Note.Root>
<Note.User
pubkey={event.pubkey}
time={event.created_at}
variant="repost"
className="h-14"
/>
<div className="relative flex flex-col gap-2 px-3">
<Note.User pubkey={repostEvent.pubkey} time={repostEvent.created_at} />
{renderContentByKind()}
<div className="flex h-14 items-center justify-between">
<Note.Pin eventId={event.id} />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={repostEvent.id} />
<Note.Reaction event={repostEvent} />
<Note.Repost event={repostEvent} />
<Note.Zap event={repostEvent} />
</div>
</div>
</div>
</Note.Root>
);
}

View File

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

View File

@@ -1,25 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useArk } from '@libs/ark/provider';
import { Note } from '..';
export function TextNote({ event }: { event: NDKEvent }) {
const ark = useArk();
const thread = ark.getEventThread({ tags: event.tags });
return (
<Note.Root>
<Note.User pubkey={event.pubkey} time={event.created_at} className="h-14 px-3" />
<Note.Thread thread={thread} className="mb-2" />
<Note.TextContent content={event.content} className="min-w-0 px-3" />
<div className="flex h-14 items-center justify-between px-3">
<Note.Pin eventId={event.id} />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={event.id} rootEventId={thread?.rootEventId} />
<Note.Reaction event={event} />
<Note.Repost event={event} />
<Note.Zap event={event} />
</div>
</div>
</Note.Root>
);
}

View File

@@ -1,37 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { useWidget } from '@libs/ark';
import { PinIcon } from '@shared/icons';
import { WIDGET_KIND } from '@utils/constants';
export function NotePin({ eventId }: { eventId: string }) {
const { addWidget } = useWidget();
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: eventId,
})
}
className="inline-flex h-7 w-max items-center justify-center gap-2 rounded-full bg-neutral-100 px-2 text-sm font-medium dark:bg-neutral-900"
>
<PinIcon className="size-4" />
Pin
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800 dark:text-neutral-100">
Pin note
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,122 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import * as Popover from '@radix-ui/react-popover';
import { useState } from 'react';
import { toast } from 'sonner';
import { ReactionIcon } from '@shared/icons';
const REACTIONS = [
{
content: '👏',
img: '/clapping_hands.png',
},
{
content: '🤪',
img: '/face_with_tongue.png',
},
{
content: '😮',
img: '/face_with_open_mouth.png',
},
{
content: '😢',
img: '/crying_face.png',
},
{
content: '🤡',
img: '/clown_face.png',
},
];
export function NoteReaction({ event }: { event: NDKEvent }) {
const [open, setOpen] = useState(false);
const [reaction, setReaction] = useState<string | null>(null);
const getReactionImage = (content: string) => {
const reaction: { img: string } = REACTIONS.find((el) => el.content === content);
return reaction.img;
};
const react = async (content: string) => {
try {
setReaction(content);
// react
await event.react(content);
setOpen(false);
} catch (e) {
toast.error(e);
}
};
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type="button"
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
{reaction ? (
<img src={getReactionImage(reaction)} alt={reaction} className="h-5 w-5" />
) : (
<ReactionIcon className="h-5 w-5 group-hover:text-blue-500" />
)}
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="select-none rounded-md bg-neutral-200 px-1 py-1 text-sm will-change-[transform,opacity] data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800"
sideOffset={0}
side="top"
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => react('👏')}
className="inline-flex h-8 w-8 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img src="/clapping_hands.png" alt="Clapping Hands" className="h-6 w-6" />
</button>
<button
type="button"
onClick={() => react('🤪')}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img
src="/face_with_tongue.png"
alt="Face with Tongue"
className="h-6 w-6"
/>
</button>
<button
type="button"
onClick={() => react('😮')}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img
src="/face_with_open_mouth.png"
alt="Face with Open Mouth"
className="h-6 w-6"
/>
</button>
<button
type="button"
onClick={() => react('😢')}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img src="/crying_face.png" alt="Crying Face" className="h-6 w-6" />
</button>
<button
type="button"
onClick={() => react('🤡')}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img src="/clown_face.png" alt="Clown Face" className="h-6 w-6" />
</button>
</div>
<Popover.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View File

@@ -1,43 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { createSearchParams, useNavigate } from 'react-router-dom';
import { ReplyIcon } from '@shared/icons';
export function NoteReply({
eventId,
rootEventId,
}: {
eventId: string;
rootEventId?: string;
}) {
const navigate = useNavigate();
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() =>
navigate({
pathname: '/new/',
search: createSearchParams({
replyTo: eventId,
rootReplyTo: rootEventId,
}).toString(),
})
}
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<ReplyIcon className="h-5 w-5 group-hover:text-blue-500" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800 dark:text-neutral-100">
Quick reply
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,50 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import * as Tooltip from '@radix-ui/react-tooltip';
import { useState } from 'react';
import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge';
import { RepostIcon } from '@shared/icons';
export function NoteRepost({ event }: { event: NDKEvent }) {
const [isRepost, setIsRepost] = useState(false);
const submit = async () => {
try {
// repost
await event.repost(true);
// update state
setIsRepost(true);
toast.success("You've reposted this post successfully");
} catch (e) {
toast.error('Repost failed, try again later');
}
};
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={submit}
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<RepostIcon
className={twMerge(
'h-5 w-5 group-hover:text-blue-600',
isRepost ? 'text-blue-500' : ''
)}
/>
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800 dark:text-neutral-100">
Repost
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,253 +0,0 @@
import { webln } from '@getalby/sdk';
import { SendPaymentResponse } from '@getalby/sdk/dist/types';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import * as Dialog from '@radix-ui/react-dialog';
import { invoke } from '@tauri-apps/api/primitives';
import { message } from '@tauri-apps/plugin-dialog';
import { QRCodeSVG } from 'qrcode.react';
import { useEffect, useRef, useState } from 'react';
import CurrencyInput from 'react-currency-input-field';
import { useNavigate } from 'react-router-dom';
import { useArk, useStorage } from '@libs/ark';
import { CancelIcon, ZapIcon } from '@shared/icons';
import { compactNumber, displayNpub } from '@utils/formater';
import { useProfile } from '@utils/hooks/useProfile';
import { sendNativeNotification } from '@utils/notification';
export function NoteZap({ event }: { event: NDKEvent }) {
const [walletConnectURL, setWalletConnectURL] = useState<string>(null);
const [amount, setAmount] = useState<string>('21');
const [zapMessage, setZapMessage] = useState<string>('');
const [invoice, setInvoice] = useState<null | string>(null);
const [isOpen, setIsOpen] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { user } = useProfile(event.pubkey);
const ark = useArk();
const storage = useStorage();
const nwc = useRef(null);
const navigate = useNavigate();
const createZapRequest = async () => {
try {
if (!ark.ndk.signer) return navigate('/new/privkey');
const zapAmount = parseInt(amount) * 1000;
const res = await event.zap(zapAmount, zapMessage);
if (!res)
return await message('Cannot create zap request', {
title: 'Zap',
type: 'error',
});
// user don't connect nwc, create QR Code for invoice
if (!walletConnectURL) return setInvoice(res);
// user connect nwc
nwc.current = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: walletConnectURL,
});
await nwc.current.enable();
// start loading
setIsLoading(true);
// send payment via nwc
const send: SendPaymentResponse = await nwc.current.sendPayment(res);
if (send) {
await sendNativeNotification(
`You've tipped ${compactNumber.format(send.amount)} sats to ${
user?.name || user?.display_name || user?.displayName
}`
);
// eose
nwc.current.close();
setIsCompleted(true);
setIsLoading(false);
// reset after 3 secs
const timeout = setTimeout(() => setIsCompleted(false), 3000);
clearTimeout(timeout);
}
} catch (e) {
nwc.current.close();
setIsLoading(false);
await message(JSON.stringify(e), { title: 'Zap', type: 'error' });
}
};
useEffect(() => {
async function getWalletConnectURL() {
const uri: string = await invoke('secure_load', {
key: `${storage.account.pubkey}-nwc`,
});
if (uri) setWalletConnectURL(uri);
}
if (isOpen) getWalletConnectURL();
return () => {
setAmount('21');
setZapMessage('');
setIsCompleted(false);
setIsLoading(false);
};
}, [isOpen]);
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<button
type="button"
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<ZapIcon className="h-5 w-5 group-hover:text-blue-500" />
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-xl rounded-xl bg-white dark:bg-black">
<div className="inline-flex w-full shrink-0 items-center justify-between px-5 py-3">
<div className="w-6" />
<Dialog.Title className="text-center font-semibold">
Send tip to{' '}
{user?.name || user?.displayName || displayNpub(event.pubkey, 16)}
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900">
<CancelIcon className="h-4 w-4" />
</Dialog.Close>
</div>
<div className="overflow-y-auto overflow-x-hidden px-5 pb-5">
{!invoice ? (
<>
<div className="relative flex h-40 flex-col">
<div className="inline-flex h-full flex-1 items-center justify-center gap-1">
<CurrencyInput
placeholder="0"
defaultValue={'21'}
value={amount}
decimalsLimit={2}
min={0} // 0 sats
max={10000} // 1M sats
maxLength={10000} // 1M sats
onValueChange={(value) => setAmount(value)}
className="w-full flex-1 border-none bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400"
/>
<span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-600 dark:text-neutral-400">
sats
</span>
</div>
<div className="inline-flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setAmount('69')}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
69 sats
</button>
<button
type="button"
onClick={() => setAmount('100')}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
100 sats
</button>
<button
type="button"
onClick={() => setAmount('200')}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
200 sats
</button>
<button
type="button"
onClick={() => setAmount('500')}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
500 sats
</button>
<button
type="button"
onClick={() => setAmount('1000')}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
1K sats
</button>
</div>
</div>
<div className="mt-4 flex w-full flex-col gap-2">
<input
name="zapMessage"
value={zapMessage}
onChange={(e) => setZapMessage(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="Enter message (optional)"
className="w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-400"
/>
<div className="flex flex-col gap-2">
{walletConnectURL ? (
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
>
{isCompleted ? (
<p className="leading-tight">Successfully zapped</p>
) : isLoading ? (
<span className="flex flex-col">
<p className="leading-tight">Waiting for approval</p>
<p className="text-xs leading-tight text-neutral-100">
Go to your wallet and approve payment request
</p>
</span>
) : (
<span className="flex flex-col">
<p className="leading-tight">Send zap</p>
<p className="text-xs leading-tight text-neutral-100">
You&apos;re using nostr wallet connect
</p>
</span>
)}
</button>
) : (
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
>
Create Lightning invoice
</button>
)}
</div>
</div>
</>
) : (
<div className="mt-3 flex flex-col items-center justify-center gap-4">
<div className="rounded-md bg-neutral-100 p-3 dark:bg-neutral-900">
<QRCodeSVG value={invoice} size={256} />
</div>
<div className="flex flex-col items-center gap-1">
<h3 className="text-lg font-medium">Scan to zap</h3>
<span className="text-center text-sm text-neutral-600 dark:text-neutral-400">
You must use Bitcoin wallet which support Lightning
<br />
such as: Blue Wallet, Bitkit, Phoenix,...
</span>
</div>
</div>
)}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,38 +0,0 @@
import { useEvent } from '@libs/ark';
import { NoteChildUser } from './childUser';
export function NoteChild({ eventId, isRoot }: { eventId: string; isRoot?: boolean }) {
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="h-4 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
if (isError) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
Failed to fetch event
</div>
</div>
);
}
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-200 dark:bg-neutral-800"></div>
<div className="break-p mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{data.content}
</div>
</div>
<NoteChildUser pubkey={data.pubkey} subtext={isRoot ? 'posted' : 'replied'} />
</div>
);
}

View File

@@ -1,64 +0,0 @@
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useMemo } from 'react';
import { useProfile } from '@libs/ark';
import { displayNpub } from '@utils/formater';
export function NoteChildUser({ pubkey, subtext }: { pubkey: string; subtext: string }) {
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo(
() => 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50)),
[pubkey]
);
const { isLoading, user } = useProfile(pubkey);
if (isLoading) {
return (
<>
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black object-cover dark:bg-white"
/>
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">{fallbackName} </div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
</div>
</div>
</>
);
}
return (
<>
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">
{user?.display_name || user?.name || user?.displayName || fallbackName}{' '}
</div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
</div>
</div>
</>
);
}

View File

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

View File

@@ -1,63 +0,0 @@
import { NDKTag } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
export function NoteArticleContent({
eventId,
tags,
}: {
eventId: string;
tags: NDKTag[];
}) {
const getMetadata = () => {
const title = tags.find((tag) => tag[0] === 'title')?.[1];
const image = tags.find((tag) => tag[0] === 'image')?.[1];
const summary = tags.find((tag) => tag[0] === 'summary')?.[1];
let publishedAt: Date | string | number = tags.find(
(tag) => tag[0] === 'published_at'
)?.[1];
publishedAt = new Date(parseInt(publishedAt) * 1000).toLocaleDateString('en-US');
return {
title,
image,
publishedAt,
summary,
};
};
const metadata = getMetadata();
return (
<Link
to={`/events/${eventId}`}
preventScrollReset={true}
className="flex w-full flex-col rounded-lg border border-neutral-200 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-900"
>
{metadata.image && (
<img
src={metadata.image}
alt={metadata.title}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-auto w-full rounded-t-lg object-cover"
/>
)}
<div className="flex flex-col gap-1 rounded-b-lg bg-neutral-200 px-3 py-3 dark:bg-neutral-800">
<h5 className="break-all font-semibold text-neutral-900 dark:text-neutral-100">
{metadata.title}
</h5>
{metadata.summary ? (
<p className="line-clamp-3 break-all text-sm text-neutral-600 dark:text-neutral-400">
{metadata.summary}
</p>
) : null}
<span className="mt-2.5 text-sm text-neutral-600 dark:text-neutral-400">
{metadata.publishedAt.toString()}
</span>
</div>
</Link>
);
}

View File

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

View File

@@ -1,23 +0,0 @@
import { twMerge } from 'tailwind-merge';
import { useRichContent } from '@libs/ark';
export function NoteTextContent({
content,
className,
}: {
content: string;
className?: string;
}) {
const { parsedContent } = useRichContent(content);
return (
<div
className={twMerge(
'break-p select-text whitespace-pre-line text-balance leading-normal',
className
)}
>
{parsedContent}
</div>
);
}

View File

@@ -1,22 +0,0 @@
import { useWidget } from '@libs/ark/hooks/useWidget';
import { WIDGET_KIND } from '@utils/constants';
export function Hashtag({ tag }: { tag: string }) {
const { addWidget } = useWidget();
return (
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.hashtag,
title: tag,
content: tag.replace('#', ''),
})
}
className="cursor-default break-all text-blue-500 hover:text-blue-600"
>
{tag}
</button>
);
}

View File

@@ -1,10 +0,0 @@
import { QRCodeSVG } from 'qrcode.react';
import { memo } from 'react';
export const Invoice = memo(function Invoice({ invoice }: { invoice: string }) {
return (
<div className="mt-2 flex items-center rounded-lg bg-neutral-200 p-2 dark:bg-neutral-800">
<QRCodeSVG value={invoice} includeMargin={true} className="rounded-lg" />
</div>
);
});

View File

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

View File

@@ -1,24 +0,0 @@
import { memo } from 'react';
import { useProfile, useWidget } from '@libs/ark';
import { WIDGET_KIND } from '@utils/constants';
export const MentionUser = memo(function MentionUser({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey);
const { addWidget } = useWidget();
return (
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.user,
title: user?.name || user?.display_name || user?.displayName,
content: pubkey,
})
}
className="break-words text-blue-500 hover:text-blue-600"
>
{'@' + (user?.name || user?.displayName || user?.username || 'unknown')}
</button>
);
});

View File

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

View File

@@ -1,57 +0,0 @@
import { downloadDir } from '@tauri-apps/api/path';
import { Window } from '@tauri-apps/api/window';
import { download } from '@tauri-apps/plugin-upload';
import { SyntheticEvent, useState } from 'react';
import { CheckCircleIcon, DownloadIcon } from '@shared/icons';
export function ImagePreview({ url }: { url: string }) {
const [downloaded, setDownloaded] = useState(false);
const downloadImage = async (e: { stopPropagation: () => void }) => {
try {
e.stopPropagation();
const downloadDirPath = await downloadDir();
const filename = url.substring(url.lastIndexOf('/') + 1);
await download(url, downloadDirPath + `/${filename}`);
setDownloaded(true);
} catch (e) {
console.error(e);
}
};
const open = () => {
return new Window('image-viewer', { url, title: 'Image Viewer' });
};
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => {
event.currentTarget.src = '/fallback-image.jpg';
};
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div onClick={open} className="group relative my-2">
<img
src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
onError={fallback}
className="h-auto w-full rounded-lg border border-neutral-200/50 object-cover dark:border-neutral-800/50"
/>
<button
type="button"
onClick={(e) => downloadImage(e)}
className="absolute right-2 top-2 z-10 hidden h-10 w-10 items-center justify-center rounded-lg bg-blue-500 group-hover:inline-flex hover:bg-blue-600"
>
{downloaded ? (
<CheckCircleIcon className="h-5 w-5 text-white" />
) : (
<DownloadIcon className="h-5 w-5 text-white" />
)}
</button>
</div>
);
}

View File

@@ -1,73 +0,0 @@
import { Link } from 'react-router-dom';
import { useOpenGraph } from '@utils/hooks/useOpenGraph';
function isImage(url: string) {
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url);
}
export function LinkPreview({ url }: { url: string }) {
const domain = new URL(url);
const { status, data } = useOpenGraph(url);
if (status === 'pending') {
return (
<div className="my-2 flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900">
<div className="h-48 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-col gap-2 px-3 py-3">
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-3/4 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<span className="mt-2.5 text-sm leading-none text-neutral-600 dark:text-neutral-400">
{domain.hostname}
</span>
</div>
</div>
);
}
if (!data.title && !data.image) {
return (
<Link
to={url}
target="_blank"
rel="noreferrer"
className="text-blue-500 hover:text-blue-600"
>
{url}
</Link>
);
}
return (
<Link
to={url}
target="_blank"
rel="noreferrer"
className="my-2 flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900"
>
{isImage(data.image) ? (
<img
src={data.image}
alt={url}
className="h-48 w-full rounded-t-lg bg-white object-cover"
/>
) : null}
<div className="flex flex-col items-start px-3 py-3">
<div className="flex flex-col items-start gap-1 text-left">
{data.title ? (
<div className="break-all text-base font-semibold text-neutral-900 dark:text-neutral-100">
{data.title}
</div>
) : null}
{data.description ? (
<div className="mb-2 line-clamp-3 break-all text-sm text-neutral-700 dark:text-neutral-400">
{data.description}
</div>
) : null}
</div>
<div className="break-all text-sm text-neutral-600 dark:text-neutral-400">
{domain.hostname}
</div>
</div>
</Link>
);
}

View File

@@ -1,19 +0,0 @@
import { MediaPlayer, MediaProvider } from '@vidstack/react';
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from '@vidstack/react/player/layouts/default';
export function VideoPreview({ url }: { url: string }) {
return (
<MediaPlayer
src={url}
className="my-2 w-full overflow-hidden rounded-lg"
aspectRatio="16/9"
load="visible"
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
);
}

View File

@@ -1,67 +0,0 @@
import { NDKSubscription } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons';
import { NDKEventWithReplies } from '@utils/types';
import { Reply } from './builds/reply';
export function NoteReplies({ eventId }: { eventId: string }) {
const ark = useArk();
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
useEffect(() => {
let sub: NDKSubscription;
let isCancelled = false;
async function fetchRepliesAndSub() {
const events = await ark.getThreads({ id: eventId });
if (!isCancelled) {
setData(events);
}
// subscribe for new replies
sub = ark.subscribe({
filter: {
'#e': [eventId],
since: Math.floor(Date.now() / 1000),
},
closeOnEose: false,
cb: (event: NDKEventWithReplies) => setData((prev) => [event, ...prev]),
});
}
fetchRepliesAndSub();
return () => {
isCancelled = true;
if (sub) sub.stop();
};
}, [eventId]);
if (!data) {
return (
<div className="mt-3">
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
</div>
);
}
return (
<div className="mt-3 flex flex-col gap-5">
<h3 className="font-semibold">Replies</h3>
{data?.length === 0 ? (
<div className="mt-2 flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
Be the first to Reply!
</p>
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} rootEvent={eventId} />)
)}
</div>
);
}

View File

@@ -1,21 +0,0 @@
import { ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
export function NoteRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={twMerge(
'mt-3 flex h-min w-full flex-col overflow-hidden rounded-xl bg-neutral-50 px-3 dark:bg-neutral-950',
className
)}
>
{children}
</div>
);
}

View File

@@ -1,38 +0,0 @@
import { twMerge } from 'tailwind-merge';
import { useWidget } from '@libs/ark';
import { WIDGET_KIND } from '@utils/constants';
import { Note } from '.';
export function NoteThread({
thread,
className,
}: {
thread: { rootEventId: string; replyEventId: string };
className?: string;
}) {
const { addWidget } = useWidget();
if (!thread) return null;
return (
<div className={twMerge('w-full px-3', className)}>
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? <Note.Child eventId={thread.rootEventId} isRoot /> : null}
{thread.replyEventId ? <Note.Child eventId={thread.replyEventId} /> : null}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: thread.rootEventId,
})
}
className="self-start text-blue-500 hover:text-blue-600"
>
Show full thread
</button>
</div>
</div>
);
}

View File

@@ -1,173 +0,0 @@
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useMemo } from 'react';
import { twMerge } from 'tailwind-merge';
import { useProfile } from '@libs/ark';
import { RepostIcon } from '@shared/icons';
import { displayNpub, formatCreatedAt } from '@utils/formater';
export function NoteUser({
pubkey,
time,
variant = 'text',
className,
}: {
pubkey: string;
time: number;
variant?: 'text' | 'repost' | 'mention';
className?: string;
}) {
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo(
() => 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50)),
[pubkey]
);
const { isLoading, user } = useProfile(pubkey);
if (variant === 'mention') {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
</div>
</div>
);
}
return (
<div className="flex h-6 items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded-md"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
</div>
</div>
);
}
if (variant === 'repost') {
if (isLoading) {
return (
<div className={twMerge('flex gap-3', className)}>
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<div className="h-6 w-6 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className={twMerge('flex gap-2', className)}>
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-neutral-900 dark:text-neutral-100/80">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</h5>
<span className="text-blue-500">reposted</span>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className={twMerge('flex items-center gap-3', className)}>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Root>
<div className="h-6 flex-1">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{fallbackName}
</div>
</div>
</div>
);
}
return (
<div className={twMerge('flex items-center gap-3', className)}>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-9 w-9 rounded-lg bg-white object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex h-6 flex-1 items-start justify-between gap-2">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</div>
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
</div>
</div>
);
}

View File

@@ -1,5 +0,0 @@
import { ReactNode } from 'react';
export function WidgetContent({ children }: { children: ReactNode }) {
return <div className="h-full w-full">{children}</div>;
}

View File

@@ -1,112 +0,0 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useQueryClient } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useWidget } from '@libs/ark';
import {
ArrowLeftIcon,
ArrowRightIcon,
HorizontalDotsIcon,
RefreshIcon,
ThreadIcon,
TrashIcon,
} from '@shared/icons';
export function WidgetHeader({
id,
title,
queryKey,
icon,
}: {
id: string;
title: string;
queryKey?: string[];
icon?: ReactNode;
}) {
const queryClient = useQueryClient();
const { removeWidget } = useWidget();
const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey });
};
const moveLeft = async () => {
removeWidget.mutate(id);
};
const moveRight = async () => {
removeWidget.mutate(id);
};
const deleteWidget = async () => {
removeWidget.mutate(id);
};
return (
<div className="flex h-11 w-full shrink-0 items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
<div className="inline-flex items-center gap-4">
<div className="h-5 w-1 rounded-full bg-blue-500" />
<div className="inline-flex items-center gap-2">
{icon ? icon : <ThreadIcon className="h-5 w-5" />}
<div className="text-sm font-medium">{title}</div>
</div>
</div>
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center"
>
<HorizontalDotsIcon className="h-4 w-4" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] flex-col overflow-hidden rounded-xl border border-neutral-100 bg-white p-2 shadow-lg shadow-neutral-200/50 focus:outline-none dark:border-neutral-900 dark:bg-neutral-950 dark:shadow-neutral-900/50">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={refresh}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<RefreshIcon className="h-5 w-5" />
Refresh
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveLeft}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowLeftIcon className="h-5 w-5" />
Move left
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveRight}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowRightIcon className="h-5 w-5" />
Move right
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-neutral-100 dark:bg-neutral-900" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={deleteWidget}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-red-500 hover:bg-red-500 hover:text-red-50 focus:outline-none"
>
<TrashIcon className="h-5 w-5" />
Delete
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -1,32 +0,0 @@
import { Resizable } from 're-resizable';
import { ReactNode, useState } from 'react';
import { twMerge } from 'tailwind-merge';
export function WidgetRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const [width, setWidth] = useState(420);
return (
<Resizable
size={{ width, height: '100%' }}
onResizeStart={(e) => e.preventDefault()}
onResizeStop={(_e, _direction, _ref, d) => {
setWidth((prevWidth) => prevWidth + d.width);
}}
minWidth={420}
maxWidth={600}
className={twMerge(
'relative flex flex-col border-r-2 border-neutral-50 hover:border-neutral-100 dark:border-neutral-950 dark:hover:border-neutral-900',
className
)}
enable={{ right: true }}
>
{children}
</Resizable>
);
}

View File

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

View File

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

View File

@@ -1,183 +0,0 @@
import { nanoid } from 'nanoid';
import { nip19 } from 'nostr-tools';
import { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import reactStringReplace from 'react-string-replace';
import {
Hashtag,
ImagePreview,
LinkPreview,
MentionNote,
MentionUser,
VideoPreview,
useStorage,
} from '@libs/ark';
const NOSTR_MENTIONS = [
'@npub1',
'nostr:npub1',
'nostr:nprofile1',
'nostr:naddr1',
'npub1',
'nprofile1',
'naddr1',
'Nostr:npub1',
'Nostr:nprofile1',
'Nostr:naddre1',
];
const NOSTR_EVENTS = [
'nostr:note1',
'note1',
'nostr:nevent1',
'nevent1',
'Nostr:note1',
'Nostr:nevent1',
];
// const BITCOINS = ['lnbc', 'bc1p', 'bc1q'];
const IMAGES = ['.jpg', '.jpeg', '.gif', '.png', '.webp', '.avif', '.tiff'];
const VIDEOS = [
'.mp4',
'.mov',
'.webm',
'.wmv',
'.flv',
'.mts',
'.avi',
'.ogv',
'.mkv',
'.mp3',
'.m3u8',
];
export function useRichContent(content: string, textmode: boolean = false) {
const storage = useStorage();
let parsedContent: string | ReactNode[] = content.replace(/\n+/g, '\n');
let linkPreview: string;
let images: string[] = [];
let videos: string[] = [];
let events: string[] = [];
const text = parsedContent;
const words = text.split(/( |\n)/);
if (!textmode) {
if (storage.settings.media) {
images = words.filter((word) => IMAGES.some((el) => word.endsWith(el)));
videos = words.filter((word) => VIDEOS.some((el) => word.endsWith(el)));
}
events = words.filter((word) => NOSTR_EVENTS.some((el) => word.startsWith(el)));
}
const hashtags = words.filter((word) => word.startsWith('#'));
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el))
);
try {
if (images.length) {
images.forEach((image) => {
parsedContent = reactStringReplace(parsedContent, image, (match, i) => (
<ImagePreview key={match + i} url={match} />
));
});
}
if (videos.length) {
videos.forEach((video) => {
parsedContent = reactStringReplace(parsedContent, video, (match, i) => (
<VideoPreview key={match + i} url={match} />
));
});
}
if (hashtags.length) {
hashtags.forEach((hashtag) => {
parsedContent = reactStringReplace(parsedContent, hashtag, (match, i) => {
if (storage.settings.hashtag) return <Hashtag key={match + i} tag={hashtag} />;
return null;
});
});
}
if (events.length) {
events.forEach((event) => {
const address = event.replace('nostr:', '').replace(/[^a-zA-Z0-9]/g, '');
const decoded = nip19.decode(address);
if (decoded.type === 'note') {
parsedContent = reactStringReplace(parsedContent, event, (match, i) => (
<MentionNote key={match + i} eventId={decoded.data} />
));
}
if (decoded.type === 'nevent') {
parsedContent = reactStringReplace(parsedContent, event, (match, i) => (
<MentionNote key={match + i} eventId={decoded.data.id} />
));
}
});
}
if (mentions.length) {
mentions.forEach((mention) => {
const address = mention
.replace('nostr:', '')
.replace('@', '')
.replace(/[^a-zA-Z0-9]/g, '');
const decoded = nip19.decode(address);
if (decoded.type === 'npub') {
parsedContent = reactStringReplace(parsedContent, mention, (match, i) => (
<MentionUser key={match + i} pubkey={decoded.data} />
));
}
if (decoded.type === 'nprofile' || decoded.type === 'naddr') {
parsedContent = reactStringReplace(parsedContent, mention, (match, i) => (
<MentionUser key={match + i} pubkey={decoded.data.pubkey} />
));
}
});
}
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => {
const url = new URL(match);
url.search = '';
if (!linkPreview && !textmode) {
linkPreview = match;
return <LinkPreview key={match + i} url={url.toString()} />;
}
return (
<Link
key={match + i}
to={url.toString()}
target="_blank"
rel="noreferrer"
className="break-all font-normal text-blue-500 hover:text-blue-600"
>
{url.toString()}
</Link>
);
});
parsedContent = reactStringReplace(parsedContent, '\n', () => {
return <div key={nanoid()} className="h-3" />;
});
if (typeof parsedContent[0] === 'string') {
parsedContent[0] = parsedContent[0].trimStart();
}
return { parsedContent };
} catch (e) {
console.warn('[parser] parse failed: ', e);
return { parsedContent };
}
}

View File

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

View File

@@ -1,8 +0,0 @@
export * from './ark';
export * from './provider';
export * from './components/widget';
export * from './components/note';
export * from './hooks/useWidget';
export * from './hooks/useRichContent';
export * from './hooks/useEvent';
export * from './hooks/useProfile';

View File

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

View File

@@ -1,430 +0,0 @@
// inspired by NDK Cache Dexie
// source: https://github.com/nostr-dev-kit/ndk/tree/master/ndk-cache-dexie
import {
Hexpubkey,
NDKCacheAdapter,
NDKEvent,
NDKFilter,
NDKRelay,
NDKSubscription,
NDKUserProfile,
profileFromEvent,
} from '@nostr-dev-kit/ndk';
import { LRUCache } from 'lru-cache';
import { NostrEvent } from 'nostr-fetch';
import { matchFilter } from 'nostr-tools';
import { LumeStorage } from '@libs/storage';
export class NDKCacheAdapterTauri implements NDKCacheAdapter {
#storage: LumeStorage;
private dirtyProfiles: Set<Hexpubkey> = new Set();
public profiles?: LRUCache<Hexpubkey, NDKUserProfile>;
readonly locking: boolean;
constructor(storage: LumeStorage) {
this.#storage = storage;
this.locking = true;
this.profiles = new LRUCache({
max: 100000,
});
setInterval(() => {
this.dumpProfiles();
}, 1000 * 10);
}
public async query(subscription: NDKSubscription): Promise<void> {
Promise.allSettled(
subscription.filters.map((filter) => this.processFilter(filter, subscription))
);
}
public async fetchProfile(pubkey: Hexpubkey) {
if (!this.profiles) return null;
let profile = this.profiles.get(pubkey);
if (!profile) {
const user = await this.#storage.getCacheUser(pubkey);
if (user) {
profile = user.profile as NDKUserProfile;
this.profiles.set(pubkey, profile);
}
}
return profile;
}
public saveProfile(pubkey: Hexpubkey, profile: NDKUserProfile) {
if (!this.profiles) return;
this.profiles.set(pubkey, profile);
this.dirtyProfiles.add(pubkey);
}
private async processFilter(
filter: NDKFilter,
subscription: NDKSubscription
): Promise<void> {
const _filter = { ...filter };
delete _filter.limit;
const filterKeys = Object.keys(_filter || {}).sort();
try {
await Promise.allSettled([
this.byKindAndAuthor(filterKeys, filter, subscription),
this.byAuthors(filterKeys, filter, subscription),
this.byKinds(filterKeys, filter, subscription),
this.byIdsQuery(filterKeys, filter, subscription),
this.byNip33Query(filterKeys, filter, subscription),
this.byTagsAndOptionallyKinds(filterKeys, filter, subscription),
]);
} catch (error) {
console.error(error);
}
}
public async setEvent(
event: NDKEvent,
filters: NDKFilter[],
relay?: NDKRelay
): Promise<void> {
if (event.kind === 0) {
if (!this.profiles) return;
const profile: NDKUserProfile = profileFromEvent(event);
this.profiles.set(event.pubkey, profile);
} else {
let addEvent = true;
if (event.isParamReplaceable()) {
const replaceableId = `${event.kind}:${event.pubkey}:${event.tagId()}`;
const existingEvent = await this.#storage.getCacheEvent(replaceableId);
if (
existingEvent &&
event.created_at &&
existingEvent.createdAt > event.created_at
) {
addEvent = false;
}
}
if (addEvent) {
this.#storage.setCacheEvent({
id: event.tagId(),
pubkey: event.pubkey,
content: event.content,
kind: event.kind!,
createdAt: event.created_at!,
relay: relay?.url,
event: JSON.stringify(event.rawEvent()),
});
// Don't cache contact lists as tags since it's expensive
// and there is no use case for it
if (event.kind !== 3) {
event.tags.forEach((tag) => {
if (tag[0].length !== 1) return;
this.#storage.setCacheEventTag({
id: `${event.id}:${tag[0]}:${tag[1]}`,
eventId: event.id,
tag: tag[0],
value: tag[1],
tagValue: tag[0] + tag[1],
});
});
}
}
}
}
/**
* Searches by authors
*/
private async byAuthors(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription
): Promise<boolean> {
const f = ['authors'];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
let foundEvents = false;
if (hasAllKeys && filter.authors) {
for (const pubkey of filter.authors) {
const events = await this.#storage.getCacheEventsByPubkey(pubkey);
for (const event of events) {
let rawEvent: NostrEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log('failed to parse event', e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
foundEvents = true;
}
}
}
return foundEvents;
}
/**
* Searches by kinds
*/
private async byKinds(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription
): Promise<boolean> {
const f = ['kinds'];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
let foundEvents = false;
if (hasAllKeys && filter.kinds) {
for (const kind of filter.kinds) {
const events = await this.#storage.getCacheEventsByKind(kind);
for (const event of events) {
let rawEvent: NostrEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log('failed to parse event', e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
foundEvents = true;
}
}
}
return foundEvents;
}
/**
* Searches by ids
*/
private async byIdsQuery(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription
): Promise<boolean> {
const f = ['ids'];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
if (hasAllKeys && filter.ids) {
for (const id of filter.ids) {
const event = await this.#storage.getCacheEvent(id);
if (!event) continue;
let rawEvent: NostrEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log('failed to parse event', e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
}
return true;
}
return false;
}
/**
* Searches by NIP-33
*/
private async byNip33Query(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription
): Promise<boolean> {
const f = ['#d', 'authors', 'kinds'];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
if (hasAllKeys && filter.kinds && filter.authors) {
for (const kind of filter.kinds) {
const replaceableKind = kind >= 30000 && kind < 40000;
if (!replaceableKind) continue;
for (const author of filter.authors) {
for (const dTag of filter['#d']) {
const replaceableId = `${kind}:${author}:${dTag}`;
const event = await this.#storage.getCacheEvent(replaceableId);
if (!event) continue;
let rawEvent: NostrEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log('failed to parse event', e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
}
}
}
return true;
}
return false;
}
/**
* Searches by kind & author
*/
private async byKindAndAuthor(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription
): Promise<boolean> {
const f = ['authors', 'kinds'];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
let foundEvents = false;
if (!hasAllKeys) return false;
if (filter.kinds && filter.authors) {
for (const kind of filter.kinds) {
for (const author of filter.authors) {
const events = await this.#storage.getCacheEventsByKindAndAuthor(kind, author);
for (const event of events) {
let rawEvent: NostrEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log('failed to parse event', e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
foundEvents = true;
}
}
}
}
return foundEvents;
}
/**
* Searches by tags and optionally filters by tags
*/
private async byTagsAndOptionallyKinds(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription
): Promise<boolean> {
for (const filterKey of filterKeys) {
const isKind = filterKey === 'kinds';
const isTag = filterKey.startsWith('#') && filterKey.length === 2;
if (!isKind && !isTag) return false;
}
const events = await this.filterByTag(filterKeys, filter);
const kinds = filter.kinds as number[];
for (const event of events) {
if (!kinds?.includes(event.kind!)) continue;
subscription.eventReceived(event, undefined, true);
}
return false;
}
private async filterByTag(
filterKeys: string[],
filter: NDKFilter
): Promise<NDKEvent[]> {
const retEvents: NDKEvent[] = [];
for (const filterKey of filterKeys) {
if (filterKey.length !== 2) continue;
const tag = filterKey.slice(1);
// const values = filter[filterKey] as string[];
const values: string[] = [];
for (const [key, value] of Object.entries(filter)) {
if (key === filterKey) values.push(value as string);
}
for (const value of values) {
const eventTags = await this.#storage.getCacheEventTagsByTagValue(tag + value);
if (!eventTags.length) continue;
const eventIds = eventTags.map((t) => t.eventId);
const events = await this.#storage.getCacheEvents(eventIds);
for (const event of events) {
let rawEvent;
try {
rawEvent = JSON.parse(event.event);
// Make sure all passed filters match the event
if (!matchFilter(filter, rawEvent)) continue;
} catch (e) {
console.log('failed to parse event', e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
ndkEvent.relay = relay;
retEvents.push(ndkEvent);
}
}
}
return retEvents;
}
private async dumpProfiles(): Promise<void> {
const profiles = [];
if (!this.profiles) return;
for (const pubkey of this.dirtyProfiles) {
const profile = this.profiles.get(pubkey);
if (!profile) continue;
profiles.push({
pubkey,
profile: JSON.stringify(profile),
createdAt: Date.now(),
});
}
if (profiles.length) {
await this.#storage.setCacheProfiles(profiles);
}
this.dirtyProfiles.clear();
}
}

View File

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

View File

@@ -1,26 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRoot } from 'react-dom/client';
import { Toaster } from 'sonner';
import { LumeProvider } from '@libs/ark';
import App from './app';
import './app.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // 10 seconds
},
},
});
const container = document.getElementById('root');
const root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<Toaster position="top-center" theme="system" closeButton />
<LumeProvider>
<App />
</LumeProvider>
</QueryClientProvider>
);

View File

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

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