wip: new onboarding

This commit is contained in:
reya 2023-10-16 14:42:19 +07:00
parent cd3b9ada5a
commit 3aa4f294f9
28 changed files with 550 additions and 559 deletions

View File

@ -19,6 +19,7 @@
},
"dependencies": {
"@evilmartians/harmony": "^1.1.0",
"@formkit/auto-animate": "^0.8.0",
"@getalby/sdk": "^2.4.0",
"@nostr-dev-kit/ndk": "^2.0.2",
"@nostr-dev-kit/ndk-cache-dexie": "^2.0.2",

View File

@ -8,6 +8,9 @@ dependencies:
'@evilmartians/harmony':
specifier: ^1.1.0
version: 1.1.0
'@formkit/auto-animate':
specifier: ^0.8.0
version: 0.8.0
'@getalby/sdk':
specifier: ^2.4.0
version: 2.4.0
@ -838,6 +841,10 @@ packages:
resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==}
dev: false
/@formkit/auto-animate@0.8.0:
resolution: {integrity: sha512-G8f7489ka0mWyi+1IEZT+xgIwcpWtRMmE2x+IrVoQ+KM1cP6VDj/TbujZjwxdb0P8w8b16/qBfViRmydbYHwMw==}
dev: false
/@getalby/sdk@2.4.0:
resolution: {integrity: sha512-aIGNwLRF9coj6koxfq7P4GtFZbFjQbnIheix39x9176PwFw4dXOdGXHPXnqioJTmeq80y+vX1yd+u/f03YGoeg==}
engines: {node: '>=14'}

View File

@ -115,7 +115,7 @@ fn main() {
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations(
"sqlite:lume.db",
"sqlite:lume_v2.db",
vec![
Migration {
version: 20230418013219,

View File

@ -3,7 +3,6 @@ import { fetch } from '@tauri-apps/plugin-http';
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
import { ReactFlowProvider } from 'reactflow';
import { CreateAccountScreen } from '@app/auth/create';
import { OnboardingScreen } from '@app/auth/onboarding';
import { ChatsScreen } from '@app/chats';
import { ErrorScreen } from '@app/error';
@ -24,17 +23,10 @@ export default function App() {
const accountLoader = async () => {
try {
const totalAccount = await db.checkAccount();
const onboarding = localStorage.getItem('onboarding');
const step = onboarding ? JSON.parse(onboarding).state.step : null;
// redirect to welcome screen if none user exist
const totalAccount = await db.checkAccount();
if (totalAccount === 0) return redirect('/auth/welcome');
// restart onboarding process
if (step) return redirect(step);
return null;
} catch (e) {
await message(e, { title: 'An unexpected error has occurred', type: 'error' });
@ -169,8 +161,10 @@ export default function App() {
},
{
path: 'create',
element: <CreateAccountScreen />,
errorElement: <ErrorScreen />,
async lazy() {
const { CreateAccountScreen } = await import('@app/auth/create');
return { Component: CreateAccountScreen };
},
},
{
path: 'import',
@ -179,6 +173,13 @@ export default function App() {
return { Component: ImportAccountScreen };
},
},
{
path: 'complete',
async lazy() {
const { CompleteScreen } = await import('@app/auth/complete');
return { Component: CompleteScreen };
},
},
{
path: 'onboarding',
element: <OnboardingScreen />,
@ -187,30 +188,23 @@ export default function App() {
{
path: '',
async lazy() {
const { OnboardStep1Screen } = await import(
'@app/auth/onboarding/step-1'
const { OnboardingListScreen } = await import(
'@app/auth/onboarding/list'
);
return { Component: OnboardStep1Screen };
return { Component: OnboardingListScreen };
},
},
{
path: 'step-2',
path: 'enrich',
async lazy() {
const { OnboardStep2Screen } = await import(
'@app/auth/onboarding/step-2'
const { OnboardEnrichScreen } = await import(
'@app/auth/onboarding/enrich'
);
return { Component: OnboardStep2Screen };
return { Component: OnboardEnrichScreen };
},
},
],
},
{
path: 'complete',
async lazy() {
const { CompleteScreen } = await import('@app/auth/complete');
return { Component: CompleteScreen };
},
},
],
},
{

View File

@ -0,0 +1,21 @@
export function CustomRelay() {
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">Personalize relay list</h5>
<p className="text-sm">
Lume offers some default relays for users who are not familiar with Nostr, but
you can consider adding more relays to discover more content.
</p>
</div>
<button
type="button"
className="mt-1 h-9 w-24 shrink-0 rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Custom
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
export function FavoriteHashtag() {
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 hashtag</h5>
<p className="text-sm">
By adding favorite hashtag, Lume will display all contents related to this
hashtag as a column
</p>
</div>
<button
type="button"
className="mt-1 h-9 w-24 shrink-0 rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Add
</button>
</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,21 @@
export function LinkList() {
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">Enable Links</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>
<button
type="button"
className="mt-1 h-9 w-24 shrink-0 rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Enable
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
export function NIP04() {
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">Enable direct message (Deprecated)</h5>
<p className="text-sm">
Send direct message to other user (NIP-04), all messages will be encrypted,
but your metadata will be leaked.
</p>
</div>
<button
type="button"
className="mt-1 h-9 w-24 shrink-0 rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Enable
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,23 @@
import { Link } from 'react-router-dom';
export function SuggestFollow() {
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>
<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>
);
}

281
src/app/auth/create.tsx Normal file
View File

@ -0,0 +1,281 @@
import { NDKEvent, NDKKind, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { downloadDir } from '@tauri-apps/api/path';
import { message, 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 { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader';
import { ArrowLeftIcon, 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;
pubkey: string;
privkey: string;
}>(null);
const {
register,
handleSubmit,
formState: { isDirty, isValid },
} = useForm();
const { db } = useStorage();
const { ndk } = useNDK();
const navigate = useNavigate();
const svgURI =
'data:image/svg+xml;utf8,' +
encodeURIComponent(minidenticon('lume new account', 90, 50));
const onSubmit = async (data: {
name: string;
display_name: string;
about: string;
}) => {
try {
setLoading(true);
const profile = {
...data,
name: data.name,
display_name: data.display_name,
bio: data.about,
};
const userPrivkey = generatePrivateKey();
const userPubkey = getPublicKey(userPrivkey);
const userNpub = nip19.npubEncode(userPubkey);
const userNsec = nip19.nsecEncode(userPrivkey);
const event = new NDKEvent(ndk);
const signer = new NDKPrivateKeySigner(userPrivkey);
event.content = JSON.stringify(profile);
event.kind = NDKKind.Metadata;
event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = userPubkey;
event.tags = [];
await event.sign(signer);
const publish = await event.publish();
if (publish) {
await db.createAccount(userNpub, userPubkey);
await db.secureSave(userPubkey, userPrivkey);
setKeys({
npub: userNpub,
nsec: userNsec,
pubkey: userPubkey,
privkey: userPrivkey,
});
setLoading(false);
} else {
toast('Create account failed');
setLoading(false);
}
} catch (e) {
return toast(e);
}
};
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,
`Generated by Lume (lume.nu)\nPublic key: ${keys.npub}\nPrivate key: ${keys.nsec}`
);
setDownloaded(true);
} // else { user cancel action }
} catch (e) {
await message(e, { title: 'Cannot download account keys', type: 'error' });
}
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="absolute left-[8px] top-4">
{!keys ? (
<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 text-neutral-900 dark:text-neutral-100">
Let&apos;s set up your Nostr account.
</h1>
<div className="flex flex-col gap-3">
{!keys ? (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<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="relative flex h-36 w-full items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
<img
src={picture || svgURI}
alt="user's avatar"
className="h-14 w-14 rounded-lg bg-black object-cover dark:bg-white"
/>
<div className="absolute bottom-2 right-2">
<AvatarUploader setPicture={setPicture} />
</div>
</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 bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="about" className="font-semibold">
Bio
</label>
<textarea
{...register('about')}
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"
/>
</div>
<button
type="submit"
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"
>
{loading ? (
<LoaderIcon className="h-5 w-4 animate-spin" />
) : (
'Create and Continue'
)}
</button>
</div>
</form>
</div>
) : (
<>
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{
opacity: 1,
y: 0,
}}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"
>
<User pubkey={keys.pubkey} variant="simple" />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 80 }}
animate={{
opacity: 1,
y: 0,
}}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"
>
<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>
<input
readOnly
value={keys.nsec}
className="h-11 rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400"
/>
</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 rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400"
/>
</div>
</div>
{!downloaded ? (
<button
type="button"
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"
>
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-9 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', { state: { newuser: true } })}
>
Finish
</motion.button>
) : null}
</div>
</div>
</div>
);
}

View File

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

View File

@ -1,170 +0,0 @@
import { downloadDir } from '@tauri-apps/api/path';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { message, save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { CopyIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function CreateStep1Screen() {
const { db } = useStorage();
const navigate = useNavigate();
const setPubkey = useOnboarding((state) => state.setPubkey);
const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [downloaded, setDownloaded] = useState(false);
const privkey = useMemo(() => generatePrivateKey(), []);
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey);
const copyPrivkey = async () => {
try {
await writeText(nsec);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
await message(e, { title: 'Cannot copy private key', type: 'error' });
}
};
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,
`Generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`
);
setDownloaded(true);
} // else { user cancel action }
} catch (e) {
await message(e, { title: 'Cannot download account keys', type: 'error' });
}
};
const submit = async () => {
try {
setLoading(true);
setPubkey(pubkey);
// save privkey
await db.secureSave(privkey, pubkey);
// save to database
await db.createAccount(npub, pubkey);
// redirect to next step
navigate('/auth/create/step-2', { replace: true });
} catch (e) {
await message(e, { title: 'Something went wrong!', type: 'error' });
}
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/create');
}, []);
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>
<h1 className="mb-2 text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
This is your new Nostr account
</h1>
<p className="mb-2 select-text text-neutral-600 dark:text-neutral-300">
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-neutral-600 dark:text-neutral-300">
Public key is used for sharing with other people so that they can find you
using the public key.
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="nsec"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Private Key
</label>
<div className="relative">
<input
readOnly
name="nsec"
value={nsec.substring(0, 5) + '**************************************'}
className="relative h-12 w-full rounded-lg bg-neutral-200 py-1 pl-3.5 pr-11 text-neutral-900 !outline-none dark:bg-neutral-800 dark:text-neutral-100"
/>
<button
type="button"
onClick={() => copyPrivkey()}
className="group absolute right-2 top-1/2 inline-flex h-7 -translate-y-1/2 transform items-center gap-1.5 rounded-md bg-neutral-300 px-2.5 text-sm text-neutral-800 hover:bg-neutral-400 hover:text-neutral-900 dark:bg-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-600 dark:hover:text-neutral-100"
>
<CopyIcon className="h-4 w-4" />
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="npub"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Public Key
</label>
<input
readOnly
name="npub"
value={npub}
className="relative h-12 w-full rounded-lg bg-neutral-200 px-3.5 py-1 text-neutral-900 !outline-none dark:bg-neutral-800 dark:text-neutral-100"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<button
type="button"
onClick={() => download()}
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-blue-500 px-6 font-medium leading-none text-white hover:bg-blue-600 focus:outline-none"
>
{downloaded ? 'Downloaded' : 'Download account keys'}
</button>
<button
type="button"
onClick={() => submit()}
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-neutral-200 px-6 font-medium leading-none text-neutral-900 hover:bg-neutral-300 focus:outline-none dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
{loading ? 'Creating...' : 'Continue'}
</button>
<span className="select-text text-center text-sm text-neutral-400 dark:text-neutral-600">
By clicking &apos;Continue&apos;, you are ensuring that your keys are saved
in a safe place. You cannot recover these keys if they are lost.
</span>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,174 +0,0 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader';
import { BannerUploader } from '@shared/bannerUploader';
import { LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { Image } from '@shared/image';
import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr';
export function CreateStep2Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
const [banner, setBanner] = useState('');
const { db } = useStorage();
const { publish } = useNostr();
const {
register,
handleSubmit,
formState: { isDirty, isValid },
} = useForm();
const onSubmit = async (data: { name: string; about: string; website: string }) => {
setLoading(true);
try {
const profile = {
...data,
name: data.name,
display_name: data.name,
bio: data.about,
website: data.website,
};
const event = await publish({
content: JSON.stringify(profile),
kind: NDKKind.Metadata,
tags: [],
});
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
if (event) {
navigate('/auth/onboarding', { replace: true });
}
} catch (e) {
console.log('error: ', e);
setLoading(false);
}
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/create/step-3');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
Personalize your Nostr profile
</h1>
<p className="text-white/70">
Nostr profile is synchronous across all Nostr clients. If you create a profile
on Lume, it will also work well with other Nostr clients. If you update your
profile on another Nostr client, it will also sync to Lume.
</p>
</div>
<div className="w-full overflow-hidden rounded-xl bg-white/10 backdrop-blur-xl">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<input type={'hidden'} {...register('picture')} value={picture} />
<input type={'hidden'} {...register('banner')} value={banner} />
<div className="relative">
<div className="relative h-36 w-full bg-white/10 backdrop-blur-xl">
{banner ? (
<Image
src={banner}
alt="user's banner"
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-white/20" />
)}
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<BannerUploader setBanner={setBanner} />
</div>
</div>
<div className="mb-5 px-4">
<div className="relative z-10 -mt-8 h-16 w-16">
<Image
src={picture}
alt="user's avatar"
className="h-16 w-16 rounded-lg object-cover ring-2 ring-white/20"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<AvatarUploader setPicture={setPicture} />
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex flex-col gap-1">
<label htmlFor="name" className="font-medium text-white">
Name *
</label>
<input
type={'text'}
{...register('name', {
required: true,
minLength: 1,
})}
spellCheck={false}
className="relative h-12 w-full rounded-lg bg-white/20 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="about" className="font-medium text-white">
Bio
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-white/20 px-3 py-2 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="website" className="font-medium text-white">
Website
</label>
<input
{...register('website', {
required: false,
})}
spellCheck={false}
className="relative h-12 w-full rounded-lg bg-white/20 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-blue-500 px-6 font-medium leading-none text-white hover:bg-blue-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -7,6 +7,7 @@ import { twMerge } from 'tailwind-merge';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon } from '@shared/icons';
import { User } from '@shared/user';
export function ImportAccountScreen() {
@ -58,24 +59,34 @@ export function ImportAccountScreen() {
}
};
const finish = async () => {
navigate('/auth/onboarding');
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="relative flex h-full w-full items-center justify-center">
<div className="absolute left-[8px] top-4">
{!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 text-neutral-900 dark:text-neutral-100">
Import your Nostr account
Import your Nostr account.
</h1>
<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="flex flex-col gap-1.5">
<label htmlFor="npub" className="font-semibold">
Enter your nostr npub:
Enter your npub:
</label>
<div className="inline-flex w-full items-center gap-2">
<input
name="npub"
type="text"
value={npub}
onChange={(e) => setNpub(e.target.value)}
@ -105,7 +116,6 @@ export function ImportAccountScreen() {
opacity: 1,
y: 0,
}}
transition={{ y: { velocity: -100 } }}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"
>
<h5 className="mb-1.5 font-semibold">Account found</h5>
@ -142,15 +152,15 @@ export function ImportAccountScreen() {
opacity: 1,
y: 0,
}}
transition={{ y: { velocity: -100 } }}
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="npub" className="font-semibold">
Enter your nostr nsec (optional):
<label htmlFor="nsec" className="font-semibold">
Enter your nsec (optional):
</label>
<div className="inline-flex w-full items-center gap-2">
<input
name="nsec"
type="text"
value={nsec}
onChange={(e) => setNsec(e.target.value)}
@ -204,15 +214,16 @@ export function ImportAccountScreen() {
</div>
</motion.div>
<motion.button
type="button"
onClick={finish}
initial={{ opacity: 0, y: 80 }}
animate={{
opacity: 1,
y: 0,
}}
transition={{ y: { velocity: -130 } }}
className="h-9 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
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"
type="button"
onClick={() =>
navigate('/auth/onboarding', { state: { newuser: false } })
}
>
Finish
</motion.button>

View File

@ -1,21 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { ArrowLeftIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
import { useNostr } from '@utils/hooks/useNostr';
import { arrayToNIP02 } from '@utils/transform';
export function OnboardStep1Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
export function OnboardEnrichScreen() {
const { publish, fetchUserData } = useNostr();
const { db } = useStorage();
const { status, data } = useQuery(['trending-profiles-widget'], async () => {
@ -29,6 +24,8 @@ export function OnboardStep1Screen() {
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]);
const navigate = useNavigate();
// toggle follow state
const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey)
@ -59,27 +56,28 @@ export function OnboardStep1Screen() {
}
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/onboarding');
}, []);
return (
<div className="flex h-full w-full flex-col justify-center">
<div className="mx-auto mb-4 w-full max-w-md border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
<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">
{loading ? 'Loading...' : 'Enrich your network'}
</h1>
<p className="text-white/70">
Choose the account you want to follow. These accounts are trending in the last
24 hours. If none of the accounts interest you, you can explore more options and
add them later.
</p>
</div>
<div className="flex w-full flex-nowrap items-center gap-4 overflow-x-auto px-4 scrollbar-none">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
<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 } }) => (
@ -87,7 +85,7 @@ export function OnboardStep1Screen() {
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="relative h-[300px] shrink-0 grow-0 basis-[250px] rounded-lg border-t border-white/10 bg-white/20 px-4 py-4 hover:bg-white/30"
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}
@ -96,42 +94,29 @@ export function OnboardStep1Screen() {
/>
{follows.includes(item.pubkey) && (
<div className="absolute right-2 top-2">
<CheckCircleIcon className="h-4 w-4 text-green-400" />
<CheckCircleIcon className="h-5 w-5 text-teal-400" />
</div>
)}
</button>
))
)}
</div>
<div className="mx-auto mt-4 w-full max-w-md">
<div className="flex flex-col gap-2">
<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-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-blue-500 px-6 font-medium leading-none text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
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 ? (
<>
<span className="w-5" />
<LoaderIcon className="h-4 w-4 animate-spin" />
<span>It might take a bit, please patient...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>Follow {follows.length} accounts & Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
<Link
to="/auth/onboarding/step-2"
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
>
Skip, you can add later
</Link>
</div>
</div>
</div>
);

View File

@ -1,12 +1,11 @@
import { message } from '@tauri-apps/plugin-dialog';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
const data = [
@ -32,15 +31,13 @@ const data = [
{ hashtag: '#dev' },
];
export function OnboardStep2Screen() {
export function OnboardHashtagScreen() {
const { db } = useStorage();
const navigate = useNavigate();
const [setStep, clearStep] = useOnboarding((state) => [state.setStep, state.clearStep]);
const [loading, setLoading] = useState(false);
const [tags, setTags] = useState(new Set<string>());
const { db } = useStorage();
const toggleTag = (tag: string) => {
if (tags.has(tag)) {
setTags((prev) => {
@ -57,9 +54,6 @@ export function OnboardStep2Screen() {
// update last login
await db.updateLastLogin();
// clear local storage
clearStep();
navigate('/auth/complete', { replace: true });
};
@ -74,9 +68,6 @@ export function OnboardStep2Screen() {
// update last login
await db.updateLastLogin();
// clear local storage
clearStep();
navigate('/auth/complete', { replace: true });
} catch (e) {
setLoading(false);
@ -84,11 +75,6 @@ export function OnboardStep2Screen() {
}
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/onboarding/step-2');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-4 border-b border-white/10 pb-4">

View File

@ -1,9 +1,5 @@
import { Outlet } from 'react-router-dom';
export function OnboardingScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<Outlet />
</div>
);
return <Outlet />;
}

View File

@ -0,0 +1,41 @@
import { Link, useLocation } from 'react-router-dom';
import { CustomRelay } from '@app/auth/components/features/customRelay';
import { FavoriteHashtag } from '@app/auth/components/features/favoriteHashtag';
import { FollowList } from '@app/auth/components/features/followList';
import { LinkList } from '@app/auth/components/features/linkList';
import { NIP04 } from '@app/auth/components/features/nip04';
import { SuggestFollow } from '@app/auth/components/features/suggestFollow';
export function OnboardingListScreen() {
const { state } = useLocation();
const { newuser }: { newuser: boolean } = state;
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 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 />
<LinkList />
<NIP04 />
<CustomRelay />
<Link
to="/"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Continue
</Link>
</div>
</div>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
@ -9,14 +9,12 @@ import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons
import { User } from '@shared/user';
import { FULL_RELAYS } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
import { useNostr } from '@utils/hooks/useNostr';
export function OnboardStep3Screen() {
export function OnboardRelaysScreen() {
const navigate = useNavigate();
const [setStep, clearStep] = useOnboarding((state) => [state.setStep, state.clearStep]);
const [loading, setLoading] = useState(false);
const [relays, setRelays] = useState(new Set<string>());
@ -81,7 +79,6 @@ export function OnboardStep3Screen() {
// update last login
await db.updateLastLogin();
clearStep();
navigate('/', { replace: true });
} catch (e) {
setLoading(false);
@ -89,11 +86,6 @@ export function OnboardStep3Screen() {
}
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/onboarding/step-3');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">

View File

@ -28,12 +28,7 @@ const NDKProvider = ({ children }: PropsWithChildren<object>) => {
data-tauri-drag-region
className="flex h-screen w-screen items-center justify-center bg-neutral-50 dark:bg-neutral-950"
>
<div className="flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-6 w-6 animate-spin text-neutral-950 dark:text-neutral-50" />
<h3 className="text-lg font-medium leading-none text-neutral-950 dark:text-neutral-50">
Connecting...
</h3>
</div>
<LoaderIcon className="h-8 w-8 animate-spin text-neutral-950 dark:text-neutral-50" />
</div>
);
}

View File

@ -18,7 +18,7 @@ export class LumeStorage {
this.platform = platform;
}
public async secureSave(value: string, key?: string) {
public async secureSave(key: string, value: string) {
return await invoke('secure_save', { key, value });
}

View File

@ -19,7 +19,7 @@ const StorageProvider = ({ children }: PropsWithChildren<object>) => {
const initLumeStorage = async () => {
try {
const sqlite = await Database.load('sqlite:lume.db');
const sqlite = await Database.load('sqlite:lume_v2.db');
const platformName = await platform();
const dir = await appConfigDir();

View File

@ -25,13 +25,14 @@ export function AvatarUploader({
<button
type="button"
onClick={() => uploadAvatar()}
className="inline-flex h-full w-full items-center justify-center rounded-lg bg-black/50 hover:bg-black/60"
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:bg-blue-200 dark:bg-blue-900 dark:text-blue-500 dark:hover:bg-blue-800"
>
{loading ? (
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<PlusIcon className="h-6 w-6 text-white" />
<PlusIcon className="h-4 w-4" />
)}
Change avatar
</button>
);
}

View File

@ -25,14 +25,16 @@ export function BannerUploader({
<button
type="button"
onClick={() => uploadBanner()}
className="inline-flex h-full w-full flex-col items-center justify-center gap-1 bg-black/40 hover:bg-black/50"
className="inline-flex h-full w-full flex-col items-center justify-center"
>
{loading ? (
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
<LoaderIcon className="h-6 w-6 animate-spin text-neutral-900 dark:text-neutral-100" />
) : (
<PlusIcon className="h-6 w-6 text-white" />
<PlusIcon className="h-6 w-6 text-neutral-900 dark:text-neutral-100" />
)}
<p className="text-sm font-medium text-white/70">Add a banner image</p>
<p className="text-sm font-semibold text-neutral-800 dark:text-neutral-200">
Add cover
</p>
</button>
);
}

View File

@ -8,7 +8,11 @@ export function AuthLayout() {
return (
<div className="h-screen w-screen bg-neutral-50 dark:bg-neutral-950">
{db.platform !== 'macos' ? <WindowTitlebar /> : null}
{db.platform !== 'macos' ? (
<WindowTitlebar />
) : (
<div data-tauri-drag-region className="h-9" />
)}
<div className="h-full w-full">
<Outlet />
<ScrollRestoration />

View File

@ -3,14 +3,7 @@ import { twMerge } from 'tailwind-merge';
import { ActiveAccount } from '@shared/accounts/active';
import { ComposerModal } from '@shared/composer';
import {
ChatsIcon,
CommunityIcon,
ExploreIcon,
HomeIcon,
NwcIcon,
RelayIcon,
} from '@shared/icons';
import { ChatsIcon, ExploreIcon, HomeIcon, NwcIcon, RelayIcon } from '@shared/icons';
export function Navigation() {
return (
@ -58,27 +51,6 @@ export function Navigation() {
</>
)}
</NavLink>
<NavLink
to="/communities"
preventScrollReset={true}
className="inline-flex flex-col items-center justify-center"
>
{({ isActive }) => (
<>
<div
className={twMerge(
'inline-flex aspect-square h-auto w-full items-center justify-center rounded-lg',
isActive
? 'bg-black/10 text-black dark:bg-white/10 dark:text-white'
: 'text-black/50 dark:text-white/50'
)}
>
<CommunityIcon className="h-6 w-6" />
</div>
<div className="text-sm text-black dark:text-white">Groups</div>
</>
)}
</NavLink>
<NavLink
to="/relays"
preventScrollReset={true}

View File

@ -1,38 +0,0 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
interface OnboardingState {
step: null | string;
pubkey: null | string;
tempPrivkey: null | string;
setPubkey: (pubkey: string) => void;
setTempPrivkey: (privkey: string) => void;
setStep: (url: string) => void;
clearStep: () => void;
}
export const useOnboarding = create<OnboardingState>()(
persist(
(set) => ({
step: null,
pubkey: null,
tempPrivkey: null,
setPubkey: (pubkey: string) => {
set({ pubkey });
},
setTempPrivkey: (privkey: string) => {
set({ tempPrivkey: privkey });
},
setStep: (url: string) => {
set({ step: url });
},
clearStep: () => {
set({ step: null, pubkey: null, tempPrivkey: null });
},
}),
{
name: 'onboarding',
storage: createJSONStorage(() => localStorage),
}
)
);