revamp onboarding and launching process

This commit is contained in:
reya 2023-11-30 09:38:58 +07:00
parent 00e4f9d357
commit f4390b29e2
41 changed files with 615 additions and 963 deletions

View File

@ -22,6 +22,7 @@
"@getalby/sdk": "^2.7.0", "@getalby/sdk": "^2.7.0",
"@nostr-dev-kit/ndk": "^2.1.1", "@nostr-dev-kit/ndk": "^2.1.1",
"@nostr-fetch/adapter-ndk": "^0.13.1", "@nostr-fetch/adapter-ndk": "^0.13.1",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-collapsible": "^1.0.3",

View File

@ -17,6 +17,9 @@ dependencies:
'@nostr-fetch/adapter-ndk': '@nostr-fetch/adapter-ndk':
specifier: ^0.13.1 specifier: ^0.13.1
version: 0.13.1(@nostr-dev-kit/ndk@2.1.1)(nostr-fetch@0.13.1) version: 0.13.1(@nostr-dev-kit/ndk@2.1.1)(nostr-fetch@0.13.1)
'@radix-ui/react-accordion':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-alert-dialog': '@radix-ui/react-alert-dialog':
specifier: ^1.0.5 specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0) version: 1.0.5(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0)
@ -888,6 +891,35 @@ packages:
'@babel/runtime': 7.23.4 '@babel/runtime': 7.23.4
dev: false dev: false
/@radix-ui/react-accordion@1.1.2(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.4
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-collapsible': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.39)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.39)(react@18.2.0)
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.39)(react@18.2.0)
'@radix-ui/react-id': 1.0.1(@types/react@18.2.39)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.39)(react@18.2.0)
'@types/react': 18.2.39
'@types/react-dom': 18.2.17
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-alert-dialog@1.0.5(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0): /@radix-ui/react-alert-dialog@1.0.5(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-OrVIOcZL0tl6xibeuGt5/+UxoT2N27KCFOPjFyfXMnchxSHZ/OW7cCX2nGlIYJrbHK/fczPcFzAwvNBB6XBNMA==} resolution: {integrity: sha512-OrVIOcZL0tl6xibeuGt5/+UxoT2N27KCFOPjFyfXMnchxSHZ/OW7cCX2nGlIYJrbHK/fczPcFzAwvNBB6XBNMA==}
peerDependencies: peerDependencies:

View File

@ -3,7 +3,6 @@ import { fetch } from '@tauri-apps/plugin-http';
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom'; import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
import { ReactFlowProvider } from 'reactflow'; import { ReactFlowProvider } from 'reactflow';
import { OnboardingScreen } from '@app/auth/onboarding';
import { ChatsScreen } from '@app/chats'; import { ChatsScreen } from '@app/chats';
import { ErrorScreen } from '@app/error'; import { ErrorScreen } from '@app/error';
import { ExploreScreen } from '@app/explore'; import { ExploreScreen } from '@app/explore';
@ -15,6 +14,7 @@ import { AppLayout } from '@shared/layouts/app';
import { AuthLayout } from '@shared/layouts/auth'; import { AuthLayout } from '@shared/layouts/auth';
import { NewLayout } from '@shared/layouts/new'; import { NewLayout } from '@shared/layouts/new';
import { NoteLayout } from '@shared/layouts/note'; import { NoteLayout } from '@shared/layouts/note';
import { OnboardingLayout } from '@shared/layouts/onboarding';
import { SettingsLayout } from '@shared/layouts/settings'; import { SettingsLayout } from '@shared/layouts/settings';
import './app.css'; import './app.css';
@ -197,34 +197,21 @@ export default function App() {
}, },
{ {
path: 'onboarding', path: 'onboarding',
element: <OnboardingScreen />, element: <OnboardingLayout />,
errorElement: <ErrorScreen />, errorElement: <ErrorScreen />,
children: [ children: [
{ {
path: '', path: '',
async lazy() { async lazy() {
const { OnboardingListScreen } = await import( const { OnboardingScreen } = await import('@app/auth/onboarding');
'@app/auth/onboarding/list' return { Component: OnboardingScreen };
);
return { Component: OnboardingListScreen };
}, },
}, },
{ {
path: 'enrich', path: 'follow',
async lazy() { async lazy() {
const { OnboardEnrichScreen } = await import( const { FollowScreen } = await import('@app/auth/follow');
'@app/auth/onboarding/enrich' return { Component: FollowScreen };
);
return { Component: OnboardEnrichScreen };
},
},
{
path: 'hashtag',
async lazy() {
const { OnboardHashtagScreen } = await import(
'@app/auth/onboarding/hashtag'
);
return { Component: OnboardHashtagScreen };
}, },
}, },
], ],

View File

@ -1,50 +0,0 @@
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
import { CheckCircleIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function AllowNotification() {
const [notification, setNotification] = useOnboarding((state) => [
state.notification,
state.toggleNotification,
]);
const allow = async () => {
let permissionGranted = await isPermissionGranted();
if (!permissionGranted) {
const permission = await requestPermission();
permissionGranted = permission === 'granted';
}
if (permissionGranted) {
setNotification();
}
};
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between gap-2">
<div>
<h5 className="font-semibold">Allow notification</h5>
<p className="text-sm">
By allowing Lume to send notifications in your OS settings, you will receive
notification messages when someone interacts with you or your content.
</p>
</div>
{notification ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<button
type="button"
onClick={allow}
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Allow
</button>
)}
</div>
</div>
);
}

View File

@ -1,100 +0,0 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { LRUCache } from 'lru-cache';
import { useState } from 'react';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function Circle() {
const { db } = useStorage();
const { ndk } = useNDK();
const [circle, setCircle] = useOnboarding((state) => [
state.circle,
state.toggleCircle,
]);
const [loading, setLoading] = useState(false);
const enableLinks = async () => {
setLoading(true);
const users = ndk.getUser({ pubkey: db.account.pubkey });
const follows = await users.follows();
if (follows.size === 0) {
setLoading(false);
return toast('You need to follow at least 1 account');
}
const lru = new LRUCache<string, string, void>({ max: 300 });
const followsAsArr = [];
// add user's follows to lru
follows.forEach((user) => {
lru.set(user.pubkey, user.pubkey);
followsAsArr.push(user.pubkey);
});
// get follows from follows
const events = await ndk.fetchEvents({
kinds: [NDKKind.Contacts],
authors: followsAsArr,
limit: 300,
});
events.forEach((event: NDKEvent) => {
event.tags.forEach((tag) => {
if (tag[0] === 'p') lru.set(tag[1], tag[1]);
});
});
// get lru values
const circleList = [...lru.values()] as string[];
// update db
await db.updateAccount('follows', JSON.stringify(followsAsArr));
await db.updateAccount('circles', JSON.stringify(circleList));
db.account.follows = followsAsArr;
db.account.circles = circleList;
// clear lru
lru.clear();
// done
await db.createSetting('circles', '1');
setCircle();
};
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between gap-2">
<div>
<h5 className="font-semibold">Enable Circle</h5>
<p className="text-sm">
Beside newsfeed from your follows, you will see more content from all people
that followed by your follows.
</p>
</div>
{circle ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<button
type="button"
onClick={enableLinks}
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Enable'}
</button>
)}
</div>
</div>
);
}

View File

@ -1,47 +0,0 @@
import { useStorage } from '@libs/storage/provider';
import { CheckCircleIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function OutboxModel() {
const { db } = useStorage();
const [outbox, setOutbox] = useOnboarding((state) => [
state.outbox,
state.toggleOutbox,
]);
const enableOutbox = async () => {
await db.createSetting('outbox', '1');
setOutbox();
};
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between gap-2">
<div>
<h5 className="font-semibold">Enable Outbox</h5>
<p className="text-sm">
When you request information about a user, Lume will automatically query the
user&apos;s outbox relays and subsequent queries will favour using those
relays for queries with that user&apos;s pubkey.
</p>
</div>
{outbox ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<button
type="button"
onClick={enableOutbox}
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Enable
</button>
)}
</div>
</div>
);
}

View File

@ -1,35 +0,0 @@
import { Link } from 'react-router-dom';
import { CheckCircleIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function FavoriteHashtag() {
const hashtag = useOnboarding((state) => state.hashtag);
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between">
<div>
<h5 className="font-semibold">Favorite topic</h5>
<p className="text-sm">
By adding favorite topic, Lume will display all contents related to this topic
for you
</p>
</div>
{hashtag ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<Link
to="/auth/onboarding/hashtag"
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Add
</Link>
)}
</div>
</div>
);
}

View File

@ -1,48 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
export function FollowList() {
const { db } = useStorage();
const { ndk } = useNDK();
const { status, data } = useQuery({
queryKey: ['follows'],
queryFn: async () => {
const user = ndk.getUser({ pubkey: db.account.pubkey });
const follows = [...(await user.follows())].map((user) => user.pubkey);
// update db
await db.updateAccount('follows', JSON.stringify(follows));
db.account.follows = follows;
return follows;
},
refetchOnWindowFocus: false,
});
return (
<div className="relative rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<h5 className="font-semibold">Your follows</h5>
<div className="mt-2 flex w-full items-center justify-center">
{status === 'pending' ? (
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
) : (
<div className="isolate flex -space-x-2">
{data.slice(0, 16).map((item) => (
<User key={item} pubkey={item} variant="stacked" />
))}
{data.length > 16 ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-200 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-800">
<span className="text-xs font-medium">+{data.length}</span>
</div>
) : null}
</div>
)}
</div>
</div>
);
}

View File

@ -1,35 +0,0 @@
import { Link } from 'react-router-dom';
import { CheckCircleIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function SuggestFollow() {
const enrich = useOnboarding((state) => state.enrich);
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between">
<div>
<h5 className="font-semibold">Enrich your network</h5>
<p className="text-sm">
Follow more people to stay up to date with everything happening around the
world.
</p>
</div>
{enrich ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<Link
to="/auth/onboarding/enrich"
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Check
</Link>
)}
</div>
</div>
);
}

View File

@ -15,7 +15,7 @@ import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader'; import { AvatarUploader } from '@shared/avatarUploader';
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons'; import { ArrowLeftIcon, InfoIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user'; import { User } from '@shared/user';
export function CreateAccountScreen() { export function CreateAccountScreen() {
@ -123,33 +123,33 @@ export function CreateAccountScreen() {
{!keys ? ( {!keys ? (
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-sm font-medium" 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 dark:bg-neutral-800 dark:text-neutral-200"> <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-5 w-5" /> <ArrowLeftIcon className="h-4 w-4" />
</div> </div>
Back Back
</button> </button>
) : null} ) : null}
</div> </div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10"> <div className="mx-auto flex w-full max-w-md flex-col gap-10">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100"> <h1 className="text-center text-2xl font-semibold">
Let&apos;s set up your account. Let&apos;s set up your account.
</h1> </h1>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{!keys ? ( {!keys ? (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"> <div className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col"> <form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<input type={'hidden'} {...register('picture')} value={picture} /> <input type={'hidden'} {...register('picture')} value={picture} />
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="font-semibold">Avatar</span> <span className="font-semibold">Avatar</span>
<div className="relative flex h-36 w-full items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800"> <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 ? ( {picture.length > 0 ? (
<img <img
src={picture} src={picture}
alt="user's avatar" alt="user's avatar"
className="h-14 w-14 rounded-xl" className="h-14 w-14 rounded-xl object-cover"
/> />
) : ( ) : (
<img <img
@ -158,11 +158,9 @@ export function CreateAccountScreen() {
className="h-14 w-14 rounded-xl bg-black dark:bg-white" className="h-14 w-14 rounded-xl bg-black dark:bg-white"
/> />
)} )}
<div className="absolute bottom-2 right-2">
<AvatarUploader setPicture={setPicture} /> <AvatarUploader setPicture={setPicture} />
</div> </div>
</div> </div>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="name" className="font-semibold"> <label htmlFor="name" className="font-semibold">
Name * Name *
@ -174,7 +172,7 @@ export function CreateAccountScreen() {
minLength: 1, minLength: 1,
})} })}
spellCheck={false} spellCheck={false}
className="h-11 rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400" 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>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@ -184,21 +182,30 @@ export function CreateAccountScreen() {
<textarea <textarea
{...register('about')} {...register('about')}
spellCheck={false} spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-neutral-200 px-3 py-2 !outline-none placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400" 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>
<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 <button
type="submit" type="submit"
disabled={!isDirty || !isValid} disabled={!isDirty || !isValid}
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" 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 ? ( {loading ? (
<LoaderIcon className="h-5 w-4 animate-spin" /> <LoaderIcon className="h-4 w-4 animate-spin" />
) : ( ) : (
'Create and Continue' 'Create and Continue'
)} )}
</button> </button>
</div> </div>
</div>
</form> </form>
</div> </div>
) : ( ) : (
@ -209,7 +216,7 @@ export function CreateAccountScreen() {
opacity: 1, opacity: 1,
y: 0, y: 0,
}} }}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200" className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
> >
<User pubkey={keys.pubkey} variant="simple" /> <User pubkey={keys.pubkey} variant="simple" />
</motion.div> </motion.div>
@ -219,7 +226,7 @@ export function CreateAccountScreen() {
opacity: 1, opacity: 1,
y: 0, y: 0,
}} }}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200" className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
> >
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<h5 className="font-semibold">Backup account</h5> <h5 className="font-semibold">Backup account</h5>
@ -227,7 +234,7 @@ export function CreateAccountScreen() {
<p className="mb-2 select-text text-sm text-neutral-800 dark:text-neutral-200"> <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 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.{' '} lose access to your account! Copy it and keep it in a safe place.{' '}
<span className="text-red-600"> <span className="text-red-500">
There is no way to reset your private key. There is no way to reset your private key.
</span> </span>
</p> </p>
@ -247,13 +254,13 @@ export function CreateAccountScreen() {
value={ value={
keys.nsec.substring(0, 10) + '**************************' keys.nsec.substring(0, 10) + '**************************'
} }
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400" 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"> <div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2">
<button <button
type="button" type="button"
onClick={copyNsec} onClick={copyNsec}
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" 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 Copy
</button> </button>
@ -267,7 +274,7 @@ export function CreateAccountScreen() {
<input <input
readOnly readOnly
value={keys.npub} value={keys.npub}
className="h-11 rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400" 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>
</div> </div>
@ -275,7 +282,7 @@ export function CreateAccountScreen() {
<button <button
type="button" type="button"
onClick={() => download()} onClick={() => download()}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600" className="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 Download account keys
</button> </button>
@ -291,9 +298,9 @@ export function CreateAccountScreen() {
opacity: 1, opacity: 1,
y: 0, y: 0,
}} }}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600" className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
type="button" type="button"
onClick={() => navigate('/auth/onboarding', { state: { newuser: true } })} onClick={() => navigate('/auth/onboarding')}
> >
Finish Finish
</motion.button> </motion.button>

275
src/app/auth/follow.tsx Normal file
View File

@ -0,0 +1,275 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import * as Accordion from '@radix-ui/react-accordion';
import { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import {
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 { ndk } = useNDK();
const { db } = useStorage();
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[]>([]);
const navigate = useNavigate();
// 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) navigate('/');
const event = new NDKEvent(ndk);
event.kind = NDKKind.Contacts;
event.tags = follows.map((item) => {
if (item.startsWith('npub')) return ['p', nip19.decode(item).data as string];
return ['p', item];
});
const publish = await event.publish();
if (publish) {
db.account.contacts = follows.map((item) => {
if (item.startsWith('npub')) return nip19.decode(item).data as string;
return item;
});
navigate('/');
}
} 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

@ -10,21 +10,22 @@ import { twMerge } from 'tailwind-merge';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon } from '@shared/icons'; import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user'; import { User } from '@shared/user';
export function ImportAccountScreen() { export function ImportAccountScreen() {
const navigate = useNavigate();
const { db } = useStorage(); const { db } = useStorage();
const { ndk } = useNDK(); const { ndk } = useNDK();
const [npub, setNpub] = useState<string>(''); const [npub, setNpub] = useState<string>('');
const [nsec, setNsec] = useState<string>(''); const [nsec, setNsec] = useState<string>('');
const [pubkey, setPubkey] = useState<undefined | string>(undefined); const [pubkey, setPubkey] = useState<undefined | string>(undefined);
const [loading, setLoading] = useState(false);
const [created, setCreated] = useState({ ok: false, remote: false }); const [created, setCreated] = useState({ ok: false, remote: false });
const [savedPrivkey, setSavedPrivkey] = useState(false); const [savedPrivkey, setSavedPrivkey] = useState(false);
const navigate = useNavigate();
const submitNpub = async () => { const submitNpub = async () => {
if (npub.length < 6) return toast.error('You must enter valid npub'); 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'); if (!npub.startsWith('npub1')) return toast.error('npub must be starts with npub1');
@ -44,8 +45,9 @@ export function ImportAccountScreen() {
try { try {
const pubkey = nip19.decode(npub.split('#')[0]).data as string; const pubkey = nip19.decode(npub.split('#')[0]).data as string;
const localSigner = NDKPrivateKeySigner.generate(); const localSigner = NDKPrivateKeySigner.generate();
await db.createSetting('nsecbunker', '1'); await db.createSetting('nsecbunker', '1');
await db.secureSave(pubkey + '-nsecbunker', localSigner.privateKey); await db.secureSave(`${pubkey}-nsecbunker`, localSigner.privateKey);
const remoteSigner = new NDKNip46Signer(ndk, npub, localSigner); const remoteSigner = new NDKNip46Signer(ndk, npub, localSigner);
// await remoteSigner.blockUntilReady(); // await remoteSigner.blockUntilReady();
@ -66,12 +68,25 @@ export function ImportAccountScreen() {
const createAccount = async () => { const createAccount = async () => {
try { try {
await db.createAccount(npub, pubkey); setLoading(true);
setCreated((prev) => ({ ...prev, ok: true }));
if (created.remote) navigate('/auth/onboarding', { state: { newuser: false } }); // add account to db
await db.createAccount(npub, pubkey);
// get account metadata
const user = ndk.getUser({ pubkey });
if (user) {
db.account.contacts = [...(await user.follows())].map((user) => user.pubkey);
db.account.relayList = await user.relayList();
}
setCreated((prev) => ({ ...prev, ok: true }));
setLoading(false);
if (created.remote) navigate('/auth/onboarding');
} catch (e) { } catch (e) {
return toast(`Create account failed: ${e}`); setLoading(false);
return toast.error(e);
} }
}; };
@ -112,11 +127,9 @@ export function ImportAccountScreen() {
) : null} ) : null}
</div> </div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10"> <div className="mx-auto flex w-full max-w-md flex-col gap-10">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100"> <h1 className="text-center text-2xl font-semibold">Import your account.</h1>
Import your account.
</h1>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"> <div className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor="npub" className="font-semibold"> <label htmlFor="npub" className="font-semibold">
Enter your public key: Enter your public key:
@ -132,21 +145,21 @@ export function ImportAccountScreen() {
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
placeholder="npub1" placeholder="npub1"
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400" 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 ? ( {!pubkey ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<button <button
type="button" type="button"
onClick={submitNpub} onClick={submitNpub}
className="h-9 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600" className="h-11 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
> >
Continue Continue
</button> </button>
<button <button
type="button" type="button"
onClick={connectNsecBunker} onClick={connectNsecBunker}
className="h-9 w-full shrink-0 rounded-lg bg-neutral-200 font-semibold text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700" 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 Continue with nsecBunker
</button> </button>
@ -162,16 +175,16 @@ export function ImportAccountScreen() {
opacity: 1, opacity: 1,
y: 0, y: 0,
}} }}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200" className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
> >
<h5 className="mb-1.5 font-semibold">Account found</h5> <h5 className="mb-1.5 font-semibold">Account found</h5>
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col gap-2">
<div className="flex h-full w-full items-center justify-between rounded-lg bg-neutral-200 p-2 dark:bg-neutral-800"> <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" /> <User pubkey={pubkey} variant="simple" />
<button <button
type="button" type="button"
onClick={changeAccount} onClick={changeAccount}
className="h-8 w-20 shrink-0 rounded-lg bg-neutral-300 text-sm font-medium text-neutral-800 hover:bg-neutral-400 dark:bg-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-600" className="h-8 w-20 shrink-0 rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
> >
Change Change
</button> </button>
@ -180,9 +193,13 @@ export function ImportAccountScreen() {
<button <button
type="button" type="button"
onClick={createAccount} onClick={createAccount}
className="h-9 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600" className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
> >
Continue {loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
'Continue'
)}
</button> </button>
) : null} ) : null}
</div> </div>
@ -215,7 +232,7 @@ export function ImportAccountScreen() {
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
placeholder="nsec1" placeholder="nsec1"
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400" 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 ? ( {nsec.length < 5 ? (
<div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2"> <div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2">
@ -282,11 +299,9 @@ export function ImportAccountScreen() {
opacity: 1, opacity: 1,
y: 0, y: 0,
}} }}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600" className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
type="button" type="button"
onClick={() => onClick={() => navigate('/auth/onboarding')}
navigate('/auth/onboarding', { state: { newuser: false } })
}
> >
Continue Continue
</motion.button> </motion.button>

148
src/app/auth/onboarding.tsx Normal file
View File

@ -0,0 +1,148 @@
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/storage/provider';
import { InfoIcon } from '@shared/icons';
export function OnboardingScreen() {
const { db } = useStorage();
const navigate = useNavigate();
const [settings, setSettings] = useState({
autoupdate: false,
outbox: false,
notification: false,
});
const next = () => {
if (!db.account.contacts) return navigate('/auth/onboarding/follow');
return navigate('/');
};
const toggleOutbox = async () => {
await db.createSetting('outbox', String(+!settings.outbox));
// update state
setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
};
const toggleAutoupdate = async () => {
await db.createSetting('autoupdate', String(+!settings.autoupdate));
db.settings.autoupdate = !settings.autoupdate;
// 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 db.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,140 +0,0 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
import { arrayToNIP02 } from '@utils/transform';
export function OnboardEnrichScreen() {
const { ndk } = useNDK();
const { db } = useStorage();
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([]);
const navigate = useNavigate();
const setEnrich = useOnboarding((state) => state.toggleEnrich);
// 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);
const tags = arrayToNIP02(follows);
const event = new NDKEvent(ndk);
event.content = '';
event.kind = NDKKind.Contacts;
event.created_at = Math.floor(Date.now() / 1000);
event.tags = tags;
const publish = await event.publish();
// redirect to next step
if (publish) {
db.account.follows = follows;
await db.updateAccount('follows', JSON.stringify(follows));
await db.updateAccount('circles', JSON.stringify(follows));
setEnrich();
navigate(-1);
} else {
setLoading(false);
}
} catch (e) {
setLoading(false);
toast(e);
}
};
return (
<div className="relative flex h-full w-full flex-col justify-center">
<div className="absolute left-[8px] top-4">
<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>
</div>
<div className="mx-auto mb-8 w-full max-w-md px-3">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Enrich your network
</h1>
</div>
<div className="flex w-full flex-nowrap items-center gap-4 overflow-x-auto px-4 scrollbar-none">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
</div>
) : (
data?.profiles.map((item: { pubkey: string; profile: { content: string } }) => (
<button
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="relative h-[300px] shrink-0 grow-0 basis-[250px] overflow-hidden rounded-lg bg-neutral-200 px-4 py-4 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
<User
pubkey={item.pubkey}
variant="large"
embedProfile={item.profile?.content}
/>
{follows.includes(item.pubkey) && (
<div className="absolute right-2 top-2">
<CheckCircleIcon className="h-5 w-5 text-teal-400" />
</div>
)}
</button>
))
)}
</div>
<div className="mx-auto mt-8 w-full max-w-md px-3">
<button
type="button"
onClick={submit}
disabled={loading || follows.length === 0}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin" />
<span>It might take a bit, please patient...</span>
</>
) : (
<span>Follow {follows.length} accounts & Continue</span>
)}
</button>
</div>
</div>
);
}

View File

@ -1,93 +0,0 @@
import { message } from '@tauri-apps/plugin-dialog';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeftIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { TOPICS, WIDGET_KIND } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
import { useWidget } from '@utils/hooks/useWidget';
export function OnboardHashtagScreen() {
const [loading, setLoading] = useState(false);
const [topic, setTopic] = useState(null);
const navigate = useNavigate();
const setHashtag = useOnboarding((state) => state.toggleHashtag);
const { addWidget } = useWidget();
const submit = async () => {
try {
setLoading(true);
setHashtag();
addWidget.mutate({
kind: WIDGET_KIND.topic,
title: topic.title,
content: JSON.stringify(topic.content),
});
navigate(-1);
} catch (e) {
setLoading(false);
await message(e, { title: 'Lume', type: 'error' });
}
};
return (
<div className="relative flex h-full w-full flex-col justify-center">
<div className="absolute left-[8px] top-4">
<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>
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10 px-3">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Choose your favorite topic
</h1>
<div className="flex flex-col gap-4">
<div className="flex w-full flex-col gap-3">
{TOPICS.map((item) => (
<button
key={item.title}
type="button"
onClick={() => setTopic(item)}
className="inline-flex h-14 items-center justify-between rounded-xl bg-neutral-100 px-4 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
<p className="font-medium">{item.title}</p>
{topic && topic.title === item.title && (
<div>
<CheckCircleIcon className="h-5 w-5 text-teal-500" />
</div>
)}
</button>
))}
</div>
<button
type="button"
onClick={submit}
disabled={loading || !topic}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin" />
<span>Adding...</span>
</>
) : (
<span>Add & Continue</span>
)}
</button>
</div>
</div>
</div>
);
}

View File

@ -1,56 +0,0 @@
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AllowNotification } from '@app/auth/components/features/allowNotification';
import { OutboxModel } from '@app/auth/components/features/enableOutbox';
import { FavoriteHashtag } from '@app/auth/components/features/favoriteHashtag';
import { FollowList } from '@app/auth/components/features/followList';
import { SuggestFollow } from '@app/auth/components/features/suggestFollow';
import { LoaderIcon } from '@shared/icons';
export function OnboardingListScreen() {
const { state } = useLocation();
const { newuser }: { newuser: boolean } = state;
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const completed = () => {
setLoading(true);
const timeout = setTimeout(() => setLoading(false), 200);
clearTimeout(timeout);
navigate('/');
};
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">
{newuser ? <SuggestFollow /> : <FollowList />}
<FavoriteHashtag />
<OutboxModel />
<AllowNotification />
<button
type="button"
onClick={completed}
className="inline-flex h-9 w-full 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>
</div>
</div>
</div>
);
}

View File

@ -1,160 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
import { useNostr } from '@utils/hooks/useNostr';
export function OnboardRelaysScreen() {
const navigate = useNavigate();
const toggleRelays = useOnboarding((state) => state.toggleRelays);
const [loading, setLoading] = useState(false);
const [relays, setRelays] = useState(new Set<string>());
const { db } = useStorage();
const { ndk } = useNDK();
const { getAllRelaysByUsers } = useNostr();
const { status, data } = useQuery({
queryKey: ['relays'],
queryFn: async () => {
return await getAllRelaysByUsers();
},
refetchOnWindowFocus: false,
});
const toggleRelay = (relay: string) => {
if (relays.has(relay)) {
setRelays((prev) => {
prev.delete(relay);
return new Set(prev);
});
} else {
setRelays((prev) => new Set(prev.add(relay)));
}
};
const submit = async () => {
try {
setLoading(true);
for (const relay of relays) {
await db.createRelay(relay);
}
const tags = Array.from(relays).map((relay) => ['r', relay.replace(/\/+$/, '')]);
const event = new NDKEvent(ndk);
event.content = '';
event.kind = 10002;
event.created_at = Math.floor(Date.now() / 1000);
event.tags = tags;
await event.publish();
toggleRelays();
navigate(-1);
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="relative flex h-full w-full flex-col justify-center">
<div className="absolute left-[8px] top-4">
<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>
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10 px-3">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Relay discovery
</h1>
<div className="flex flex-col gap-4">
<div className="flex h-[420px] w-full flex-col overflow-y-auto rounded-xl bg-neutral-100 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 text-neutral-900 dark:text-neutral-100" />
</div>
) : data.size === 0 ? (
<div className="flex h-full w-full items-center justify-center px-6">
<p className="text-center text-neutral-300 dark:text-neutral-600">
Lume couldn&apos;t find any relays from your follows.
<br />
You can skip this step and use default relays instead.
</p>
</div>
) : (
[...data].map(([key, value]) => (
<button
key={key}
type="button"
onClick={() => toggleRelay(key)}
className="inline-flex transform items-start justify-between px-4 py-2 hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<div className="flex w-full items-center justify-between">
<div className="inline-flex items-center gap-2">
<div className="pt-1.5">
{relays.has(key) ? (
<CheckCircleIcon className="h-4 w-4 text-teal-500" />
) : (
<CheckCircleIcon className="h-4 w-4 text-neutral-300 dark:text-neutral-700" />
)}
</div>
<p className="max-w-[15rem] truncate">{key.replace(/\/+$/, '')}</p>
</div>
<div className="inline-flex items-center gap-2">
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
Used by
</span>
<div className="isolate flex -space-x-2">
{value.slice(0, 3).map((item) => (
<User key={item} pubkey={item} variant="stacked" />
))}
{value.length > 3 ? (
<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>
</div>
</button>
))
)}
</div>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin" />
<span>Adding...</span>
</>
) : (
<span>Add {relays.size} relays & Continue</span>
)}
</button>
</div>
</div>
</div>
);
}

View File

@ -7,7 +7,7 @@ export function WelcomeScreen() {
<div className="text-center"> <div className="text-center">
<img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" /> <img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" />
<h1 className="text-2xl"> <h1 className="text-2xl">
Welcome to <span className="font-semibold">Lume</span> Welcome to <span className="font-bold">Lume</span>
</h1> </h1>
</div> </div>
<div className="flex flex-col gap-2 px-8"> <div className="flex flex-col gap-2 px-8">

View File

@ -67,7 +67,7 @@ export const UserWithDrawer = memo(function UserWithDrawer({
}; };
useEffect(() => { useEffect(() => {
if (db.account.follows.includes(pubkey)) { if (db.account.contacts.includes(pubkey)) {
setFollowed(true); setFollowed(true);
} }
}, []); }, []);

View File

@ -28,7 +28,7 @@ export function ExploreScreen() {
const { getContactsByPubkey } = useNostr(); const { getContactsByPubkey } = useNostr();
const { project } = useReactFlow(); const { project } = useReactFlow();
const defaultContacts = useMemo(() => getMultipleRandom(db.account.follows, 10), []); const defaultContacts = useMemo(() => getMultipleRandom(db.account.contacts, 10), []);
const reactFlowWrapper = useRef(null); const reactFlowWrapper = useRef(null);
const connectingNodeId = useRef(null); const connectingNodeId = useRef(null);

View File

@ -32,8 +32,8 @@ export function MentionPopup({ editor }: { editor: Editor }) {
className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900" 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"> <div className="flex flex-col gap-1 py-1">
{db.account.follows.length > 0 ? ( {db.account.contacts.length > 0 ? (
db.account.follows.map((item) => ( db.account.contacts.map((item) => (
<button key={item} type="button" onClick={() => insertMention(item)}> <button key={item} type="button" onClick={() => insertMention(item)}>
<MentionPopupItem pubkey={item} /> <MentionPopupItem pubkey={item} />
</button> </button>

View File

@ -113,12 +113,6 @@ export function GeneralSettingScreen() {
...prev, ...prev,
hashtag: !!parseInt(item.value), hashtag: !!parseInt(item.value),
})); }));
if (item.key === 'notification')
setSettings((prev) => ({
...prev,
notification: !!parseInt(item.value),
}));
}); });
} }

View File

@ -72,7 +72,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
}; };
useEffect(() => { useEffect(() => {
if (db.account.follows.includes(pubkey)) { if (db.account.contacts.includes(pubkey)) {
setFollowed(true); setFollowed(true);
} }
}, []); }, []);

View File

@ -20,7 +20,7 @@ export const NDKInstance = () => {
[ndk] [ndk]
); );
// TODO: fully support NIP-11 // eslint-disable-next-line @typescript-eslint/no-unused-vars
async function getExplicitRelays() { async function getExplicitRelays() {
try { try {
// get relays // get relays
@ -68,13 +68,13 @@ export const NDKInstance = () => {
// NIP-46 Signer // NIP-46 Signer
if (nsecbunker) { if (nsecbunker) {
const localSignerPrivkey = await db.secureLoad(db.account.pubkey + '-nsecbunker'); const localSignerPrivkey = await db.secureLoad(db.account.pubkey + '-nsecbunker');
if (!localSignerPrivkey) return null;
const localSigner = new NDKPrivateKeySigner(localSignerPrivkey); const localSigner = new NDKPrivateKeySigner(localSignerPrivkey);
if (!localSigner) return null;
// await remoteSigner.blockUntilReady(); // await remoteSigner.blockUntilReady();
return new NDKNip46Signer(ndk, db.account.id, localSigner); return new NDKNip46Signer(ndk, db.account.id, localSigner);
} }
// Private key Signer // Private Key Signer
const userPrivkey = await db.secureLoad(db.account.pubkey); const userPrivkey = await db.secureLoad(db.account.pubkey);
if (!userPrivkey) return null; if (!userPrivkey) return null;
return new NDKPrivateKeySigner(userPrivkey); return new NDKPrivateKeySigner(userPrivkey);
@ -84,18 +84,23 @@ export const NDKInstance = () => {
try { try {
const outboxSetting = await db.getSettingValue('outbox'); const outboxSetting = await db.getSettingValue('outbox');
const bunkerSetting = await db.getSettingValue('nsecbunker'); const bunkerSetting = await db.getSettingValue('nsecbunker');
const signer = await getSigner(!!parseInt(bunkerSetting));
const explicitRelayUrls = await getExplicitRelays(); const bunker = !!parseInt(bunkerSetting);
const outbox = !!parseInt(outboxSetting);
const signer = await getSigner(bunker);
const explicitRelayUrls = await db.getExplicitRelayUrls();
const tauriAdapter = new NDKCacheAdapterTauri(db); const tauriAdapter = new NDKCacheAdapterTauri(db);
const instance = new NDK({ const instance = new NDK({
explicitRelayUrls, explicitRelayUrls,
cacheAdapter: tauriAdapter, cacheAdapter: tauriAdapter,
outboxRelayUrls: ['wss://purplepag.es'], outboxRelayUrls: ['wss://purplepag.es'],
blacklistRelayUrls: [], enableOutboxModel: outbox,
enableOutboxModel: !!parseInt(outboxSetting),
}); });
instance.signer = signer;
// add signer if exist
if (signer) instance.signer = signer;
// connect // connect
await instance.connect(); await instance.connect();
@ -104,17 +109,8 @@ export const NDKInstance = () => {
if (db.account) { if (db.account) {
const user = instance.getUser({ pubkey: db.account.pubkey }); const user = instance.getUser({ pubkey: db.account.pubkey });
if (user) { if (user) {
const follows = [...(await user.follows())].map((user) => user.pubkey); db.account.contacts = [...(await user.follows())].map((user) => user.pubkey);
const relayList = await user.relayList(); db.account.relayList = await user.relayList();
// update user's follows
await db.updateAccount('follows', JSON.stringify(follows));
if (relayList)
// update user's relays
for (const relay of relayList.relays) {
await db.createRelay(relay);
}
} }
} }
@ -129,7 +125,6 @@ export const NDKInstance = () => {
okLabel: 'Yes', okLabel: 'Yes',
} }
); );
if (yes) relaunch(); if (yes) relaunch();
} }
} }

View File

@ -188,20 +188,9 @@ export class LumeStorage {
'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;' 'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
); );
if (results.length > 0) { if (results.length) {
const account = results[0]; this.account = results[0];
this.account.contacts = [];
if (typeof account.follows === 'string')
account.follows = JSON.parse(account.follows) ?? [];
if (typeof account.circles === 'string')
account.circles = JSON.parse(account.circles) ?? [];
if (typeof account.last_login_at === 'string')
account.last_login_at = parseInt(account.last_login_at);
this.account = account;
return account;
} else { } else {
console.log('no active account, please create new account'); console.log('no active account, please create new account');
return null; return null;
@ -214,7 +203,7 @@ export class LumeStorage {
[pubkey] [pubkey]
); );
if (existAccounts.length > 0) { if (existAccounts.length) {
await this.db.execute("UPDATE accounts SET is_active = '1' WHERE pubkey = $1;", [ await this.db.execute("UPDATE accounts SET is_active = '1' WHERE pubkey = $1;", [
pubkey, pubkey,
]); ]);
@ -225,8 +214,7 @@ export class LumeStorage {
); );
} }
const account = await this.getActiveAccount(); return await this.getActiveAccount();
return account;
} }
public async updateAccount(column: string, value: string) { public async updateAccount(column: string, value: string) {
@ -241,15 +229,6 @@ export class LumeStorage {
} }
} }
public async updateLastLogin() {
const now = Math.floor(Date.now() / 1000);
this.account.last_login_at = now;
return await this.db.execute(
'UPDATE accounts SET last_login_at = $1 WHERE id = $2;',
[now, this.account.id]
);
}
public async getWidgets() { public async getWidgets() {
const widgets: Array<Widget> = await this.db.select( const widgets: Array<Widget> = await this.db.select(
'SELECT * FROM widgets WHERE account_id = $1 ORDER BY created_at DESC;', 'SELECT * FROM widgets WHERE account_id = $1 ORDER BY created_at DESC;',

View File

@ -1,7 +1,7 @@
import { message } from '@tauri-apps/plugin-dialog'; import { message } from '@tauri-apps/plugin-dialog';
import { Dispatch, SetStateAction, useState } from 'react'; import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
@ -37,14 +37,9 @@ export function AvatarUploader({
<button <button
type="button" type="button"
onClick={() => uploadAvatar()} onClick={() => uploadAvatar()}
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-100 px-1.5 py-1 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" 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 ? ( {loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Change avatar'}
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<PlusIcon className="h-4 w-4" />
)}
Change avatar
</button> </button>
); );
} }

View File

@ -7,16 +7,18 @@ export function AuthLayout() {
const { db } = useStorage(); const { db } = useStorage();
return ( return (
<div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950"> <div className="flex h-screen w-screen flex-col">
{db.platform !== 'macos' ? ( {db.platform !== 'macos' ? (
<WindowTitlebar /> <WindowTitlebar />
) : ( ) : (
<div data-tauri-drag-region className="h-9" /> <div data-tauri-drag-region className="h-9" />
)} )}
<div className="flex h-full min-h-0 w-full"> <div className="h-full w-full px-2.5 pb-2.5 pt-1">
<div className="flex h-full min-h-0 w-full rounded-lg bg-white p-3 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)]">
<Outlet /> <Outlet />
<ScrollRestoration /> <ScrollRestoration />
</div> </div>
</div> </div>
</div>
); );
} }

View File

@ -1,5 +1,5 @@
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
export function OnboardingScreen() { export function OnboardingLayout() {
return <Outlet />; return <Outlet />;
} }

View File

@ -33,13 +33,13 @@ export function TitleBar({
<div className="col-span-1 flex justify-center"> <div className="col-span-1 flex justify-center">
{id === '9999' ? ( {id === '9999' ? (
<div className="isolate flex -space-x-2"> <div className="isolate flex -space-x-2">
{db.account.follows {db.account.contacts
?.slice(0, 8) ?.slice(0, 8)
.map((item) => <User key={item} pubkey={item} variant="ministacked" />)} .map((item) => <User key={item} pubkey={item} variant="ministacked" />)}
{db.account.follows?.length > 8 ? ( {db.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"> <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"> <span className="text-[8px] font-medium">
+{db.account.follows?.length - 8} +{db.account.contacts?.length - 8}
</span> </span>
</div> </div>
) : null} ) : null}

View File

@ -4,7 +4,7 @@ import { minidenticon } from 'minidenticons';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { RepostIcon, WorldIcon } from '@shared/icons'; import { RepostIcon } from '@shared/icons';
import { NIP05 } from '@shared/nip05'; import { NIP05 } from '@shared/nip05';
import { MoreActions } from '@shared/notes'; import { MoreActions } from '@shared/notes';
@ -133,10 +133,9 @@ export const User = memo(function User({
return ( return (
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="h-14 w-14 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" /> <div className="h-14 w-14 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
<div> <div className="flex flex-col gap-1.5">
<div className="h-3.5 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" /> <div className="h-3.5 w-36 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 className="h-4 w-24 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>
</div> </div>
); );
@ -150,38 +149,24 @@ export const User = memo(function User({
alt={pubkey} alt={pubkey}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
className="h-14 w-14 rounded-lg object-cover" className="h-11 w-11 rounded-lg object-cover"
/> />
<Avatar.Fallback delayMs={300}> <Avatar.Fallback delayMs={300}>
<img <img
src={svgURI} src={svgURI}
alt={pubkey} alt={pubkey}
className="h-14 w-14 rounded-lg bg-black dark:bg-white" className="h-11 w-11 rounded-lg bg-black dark:bg-white"
/> />
</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<div className="flex h-full flex-col items-start justify-between"> <div className="flex flex-col items-start text-start">
<div className="flex flex-col items-start gap-1 text-start"> <p className="max-w-[15rem] truncate text-lg font-semibold">
<p className="max-w-[15rem] truncate text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName} {user?.name || user?.display_name || user?.displayName}
</p> </p>
<p className="break-p prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-500"> <p className="break-p prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert">
{user?.about || user?.bio || 'No bio'} {user?.about || user?.bio || 'No bio'}
</p> </p>
</div> </div>
<div className="flex flex-col gap-2">
{user?.website ? (
<Link
to={user?.website}
target="_blank"
className="inline-flex items-center gap-2 text-sm text-neutral-900 dark:text-neutral-100/70"
>
<WorldIcon className="h-4 w-4" />
<p className="max-w-[10rem] truncate">{user?.website}</p>
</Link>
) : null}
</div>
</div>
</div> </div>
); );
} }

View File

@ -61,7 +61,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
}; };
useEffect(() => { useEffect(() => {
if (db.account.follows.includes(pubkey)) { if (db.account.contacts.includes(pubkey)) {
setFollowed(true); setFollowed(true);
} }
}, []); }, []);

View File

@ -40,7 +40,7 @@ export function ArticleWidget({ widget }: { widget: Widget }) {
} else { } else {
filter = { filter = {
kinds: [NDKKind.Article], kinds: [NDKKind.Article],
authors: db.account.follows, authors: db.account.contacts,
}; };
} }

View File

@ -40,7 +40,7 @@ export function FileWidget({ widget }: { widget: Widget }) {
} else { } else {
filter = { filter = {
kinds: [1063], kinds: [1063],
authors: db.account.follows, authors: db.account.contacts,
}; };
} }

View File

@ -39,7 +39,7 @@ export function NewsfeedWidget() {
relayUrls, relayUrls,
{ {
kinds: [NDKKind.Text, NDKKind.Repost], kinds: [NDKKind.Text, NDKKind.Repost],
authors: db.account.follows, authors: db.account.contacts,
}, },
FETCH_LIMIT, FETCH_LIMIT,
{ asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal } { asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal }

View File

@ -54,7 +54,6 @@ export function NotificationWidget() {
if (!lastEvent) return; if (!lastEvent) return;
return lastEvent.created_at - 1; return lastEvent.created_at - 1;
}, },
enabled: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnMount: false, refetchOnMount: false,
refetchOnReconnect: false, refetchOnReconnect: false,

View File

@ -96,7 +96,7 @@ export function AddGroupFeeds({ currentWidgetId }: { currentWidgetId: string })
Users Users
</span> </span>
<div className="flex h-[420px] flex-col overflow-y-auto rounded-xl bg-neutral-100 py-2 dark:bg-neutral-900"> <div className="flex h-[420px] flex-col overflow-y-auto rounded-xl bg-neutral-100 py-2 dark:bg-neutral-900">
{db.account.follows.map((item: string) => ( {db.account.contacts.map((item: string) => (
<button <button
key={item} key={item}
type="button" type="button"

View File

@ -35,7 +35,7 @@ export function LiveUpdater({ status }: { status: QueryStatus }) {
const filter: NDKFilter = { const filter: NDKFilter = {
kinds: [NDKKind.Text, NDKKind.Repost], kinds: [NDKKind.Text, NDKKind.Repost],
authors: db.account.follows, authors: db.account.contacts,
since: Math.floor(Date.now() / 1000), since: Math.floor(Date.now() / 1000),
}; };

View File

@ -44,7 +44,7 @@ export function NostrBandUserProfile({ data }: { data: Profile }) {
}; };
useEffect(() => { useEffect(() => {
if (db.account.follows.includes(data.pubkey)) { if (db.account.contacts.includes(data.pubkey)) {
setFollowed(true); setFollowed(true);
} }
}, []); }, []);

View File

@ -219,7 +219,7 @@ export function useNostr() {
const relayMap = new Map<string, string[]>(); const relayMap = new Map<string, string[]>();
const relayEvents = fetcher.fetchLatestEventsPerAuthor( const relayEvents = fetcher.fetchLatestEventsPerAuthor(
{ {
authors: db.account.follows, authors: db.account.contacts,
relayUrls: relayUrls, relayUrls: relayUrls,
}, },
{ kinds: [NDKKind.RelayList] }, { kinds: [NDKKind.RelayList] },

View File

@ -1,5 +1,6 @@
import { NDKUserProfile } from '@nostr-dev-kit/ndk'; import { NDKUserProfile } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
@ -17,10 +18,13 @@ export function useProfile(pubkey: string, embed?: string) {
return profile; return profile;
} }
const cleanPubkey = pubkey.replace(/[^a-zA-Z0-9]/g, ''); let hexstring = pubkey.replace(/[^a-zA-Z0-9]/g, '');
const user = ndk.getUser({ pubkey: cleanPubkey }); if (hexstring.startsWith('npub1'))
hexstring = nip19.decode(hexstring).data as string;
const user = ndk.getUser({ pubkey: hexstring });
if (!user) return Promise.reject(new Error("user's profile not found")); if (!user) return Promise.reject(new Error("user's profile not found"));
return await user.fetchProfile(); return await user.fetchProfile();
}, },
staleTime: Infinity, staleTime: Infinity,

18
src/utils/types.d.ts vendored
View File

@ -1,4 +1,4 @@
import { type NDKEvent, type NDKUserProfile } from '@nostr-dev-kit/ndk'; import { type NDKEvent, NDKRelayList, type NDKUserProfile } from '@nostr-dev-kit/ndk';
import { type Response } from '@tauri-apps/plugin-http'; import { type Response } from '@tauri-apps/plugin-http';
export interface RichContent { export interface RichContent {
@ -21,18 +21,16 @@ export interface DBEvent {
richContent?: RichContent; richContent?: RichContent;
} }
export interface Account extends NDKUserProfile { export interface Account {
id: string; id: string;
pubkey: string; pubkey: string;
follows: null | string[];
circles: null | string[];
is_active: number; is_active: number;
last_login_at: number; contacts: string[];
} relayList: NDKRelayList;
/**
export interface Profile extends NDKUserProfile { * @deprecated Use contacts instead
ident?: string; */
pubkey?: string; follows: string[];
} }
export interface WidgetGroup { export interface WidgetGroup {