Merge pull request #87 from luminous-devs/feat/improve-onboarding

merge now, improve later
This commit is contained in:
Ren Amamiya 2023-09-17 08:44:16 +07:00 committed by GitHub
commit c590e290e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 705 additions and 407 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -18,7 +18,6 @@
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write" "**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
}, },
"dependencies": { "dependencies": {
"@ctrl/magnet-link": "^3.1.2",
"@getalby/sdk": "^2.4.0", "@getalby/sdk": "^2.4.0",
"@nostr-dev-kit/ndk": "^1.0.0", "@nostr-dev-kit/ndk": "^1.0.0",
"@nostr-fetch/adapter-ndk": "^0.12.2", "@nostr-fetch/adapter-ndk": "^0.12.2",
@ -38,7 +37,6 @@
"@tiptap/react": "^2.1.8", "@tiptap/react": "^2.1.8",
"@tiptap/starter-kit": "^2.1.8", "@tiptap/starter-kit": "^2.1.8",
"@tiptap/suggestion": "^2.1.8", "@tiptap/suggestion": "^2.1.8",
"@void-cat/api": "^1.0.7",
"dayjs": "^1.11.9", "dayjs": "^1.11.9",
"destr": "^2.0.1", "destr": "^2.0.1",
"get-urls": "^12.1.0", "get-urls": "^12.1.0",
@ -53,7 +51,6 @@
"react-currency-input-field": "^3.6.11", "react-currency-input-field": "^3.6.11",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.46.1", "react-hook-form": "^7.46.1",
"react-hotkeys-hook": "^4.4.1",
"react-markdown": "^8.0.7", "react-markdown": "^8.0.7",
"react-player": "^2.13.0", "react-player": "^2.13.0",
"react-router-dom": "^6.15.0", "react-router-dom": "^6.15.0",
@ -64,7 +61,6 @@
"tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1", "tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1",
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1", "tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1",
"tauri-plugin-upload-api": "github:tauri-apps/tauri-plugin-upload#v1", "tauri-plugin-upload-api": "github:tauri-apps/tauri-plugin-upload#v1",
"tippy.js": "^6.3.7",
"zustand": "^4.4.1" "zustand": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -205,15 +205,15 @@ const router = createBrowserRouter([
return { Component: OnboardStep2Screen }; return { Component: OnboardStep2Screen };
}, },
}, },
{
path: 'step-3',
async lazy() {
const { OnboardStep3Screen } = await import('@app/auth/onboarding/step-3');
return { Component: OnboardStep3Screen };
},
},
], ],
}, },
{
path: 'complete',
async lazy() {
const { CompleteScreen } = await import('@app/auth/complete');
return { Component: CompleteScreen };
},
},
{ {
path: 'unlock', path: 'unlock',
async lazy() { async lazy() {
@ -235,13 +235,6 @@ const router = createBrowserRouter([
return { Component: ResetScreen }; return { Component: ResetScreen };
}, },
}, },
{
path: 'hard-reset',
async lazy() {
const { HardResetScreen } = await import('@app/auth/hardReset');
return { Component: HardResetScreen };
},
},
], ],
}, },
{ {

43
src/app/auth/complete.tsx Normal file
View File

@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
export function CompleteScreen() {
const navigate = useNavigate();
const [count, setCount] = useState(3);
useEffect(() => {
let counter: NodeJS.Timeout;
if (count > 0) {
counter = setTimeout(() => setCount(count - 1), 1000);
}
if (count === 0) {
navigate('/', { replace: true });
}
return () => {
clearTimeout(counter);
};
}, [count]);
return (
<div className="relative flex h-full w-full flex-col items-center justify-center">
<div className="mx-auto flex max-w-xl flex-col gap-1.5 text-center">
<h1 className="text-2xl font-light leading-none text-white">
<span className="font-semibold">You&apos;re ready</span>, redirecting in {count}
...
</h1>
<p className="text-white/70">
Thank you for using Lume. Lume doesn&apos;t use telemetry. If you encounter any
problems, please submit a report via the &quot;Report Issue&quot; button.
<br />
You can find it while using the application.
</p>
</div>
<div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 transform items-center justify-center">
<img src="/lume.png" alt="lume" className="h-auto w-1/5" />
</div>
</div>
);
}

View File

@ -1,7 +1,9 @@
import { Link } from 'react-router-dom';
import { WorldIcon } from '@shared/icons';
import { Image } from '@shared/image'; import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }) { export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }) {
const { status, user } = useProfile(pubkey, fallback); const { status, user } = useProfile(pubkey, fallback);
@ -9,7 +11,7 @@ export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse rounded-md bg-white/10 backdrop-blur-xl" /> <div className="relative h-14 w-14 shrink-0 animate-pulse rounded-md bg-white/10 backdrop-blur-xl" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start"> <div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10 backdrop-blur-xl" /> <span className="h-4 w-1/2 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" /> <span className="h-3 w-1/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
@ -19,19 +21,33 @@ export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }
} }
return ( return (
<div className="flex items-center gap-2.5"> <div className="flex h-full w-full flex-col gap-2.5">
<Image <Image
src={user?.picture || user?.image} src={user?.picture || user?.image}
alt={pubkey} alt={pubkey}
className="h-10 w-10 shrink-0 rounded-lg object-cover" className="h-14 w-14 shrink-0 rounded-lg object-cover"
/> />
<div className="flex w-full flex-1 flex-col items-start text-start"> <div className="flex h-full flex-col items-start justify-between">
<p className="max-w-[15rem] truncate font-medium leading-tight text-white"> <div className="flex flex-col items-start gap-1 text-start">
{user?.name || user?.display_name} <p className="max-w-[15rem] truncate text-lg font-semibold leading-none text-white">
</p> {user?.name || user?.display_name}
<span className="max-w-[15rem] truncate leading-tight text-white/50"> </p>
{displayNpub(pubkey, 16)} <p className="line-clamp-6 break-all text-white/70">
</span> {user?.about || user?.bio || 'No bio'}
</p>
</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-white/70"
>
<WorldIcon className="h-4 w-4" />
<p className="max-w-[10rem] truncate">{user.website}</p>
</Link>
) : null}
</div>
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,38 @@
import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function UserImport({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return (
<div className="flex items-center gap-2.5">
<div className="12 12 relative shrink-0 animate-pulse rounded-lg bg-white/10 backdrop-blur-xl" />
<div className="flex flex-col gap-1">
<span className="h-5 w-1/2 animate-pulse rounded bg-white/10" />
<span className="h-4 w-1/3 animate-pulse rounded bg-white/10" />
</div>
</div>
);
}
return (
<div className="flex items-center gap-2.5">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
/>
<div className="flex w-full flex-col gap-1">
<h3 className="max-w-[15rem] truncate text-lg font-semibold leading-none text-white">
{user?.name || user?.display_name}
</h3>
<p className="leading-none text-white/70">
{user?.nip05 || user?.username || displayNpub(pubkey, 16)}
</p>
</div>
</div>
);
}

View File

@ -1,13 +1,14 @@
import { BaseDirectory, writeTextFile } from '@tauri-apps/api/fs'; import { writeText } from '@tauri-apps/api/clipboard';
import { message, save } from '@tauri-apps/api/dialog';
import { writeTextFile } from '@tauri-apps/api/fs';
import { downloadDir } from '@tauri-apps/api/path';
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools'; import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { Button } from '@shared/button'; import { CopyIcon } from '@shared/icons';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding'; import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold'; import { useStronghold } from '@stores/stronghold';
@ -21,8 +22,8 @@ export function CreateStep1Screen() {
const setPubkey = useOnboarding((state) => state.setPubkey); const setPubkey = useOnboarding((state) => state.setPubkey);
const setStep = useOnboarding((state) => state.setStep); const setStep = useOnboarding((state) => state.setStep);
const [privkeyInput, setPrivkeyInput] = useState('password');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [downloaded, setDownloaded] = useState(false); const [downloaded, setDownloaded] = useState(false);
const privkey = useMemo(() => generatePrivateKey(), []); const privkey = useMemo(() => generatePrivateKey(), []);
@ -30,27 +31,39 @@ export function CreateStep1Screen() {
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey); const nsec = nip19.nsecEncode(privkey);
// toggle private key const download = async () => {
const showPrivateKey = () => { try {
if (privkeyInput === 'password') { const downloadPath = await downloadDir();
setPrivkeyInput('text'); const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
} else { const filePath = await save({
setPrivkeyInput('password'); 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 download = async () => { const copyPrivkey = async () => {
await writeTextFile( try {
`nostr_keys_${new Date().toISOString().slice(0, 10)}.txt`, await writeText(nsec);
`Generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`, setCopied(true);
{
dir: BaseDirectory.Download, setTimeout(() => setCopied(false), 3000);
} } catch (e) {
); await message(e, { title: 'Cannot copy private key', type: 'error' });
setDownloaded(true); }
}; };
const submit = () => { const submit = async () => {
setLoading(true); setLoading(true);
// update state // update state
@ -59,7 +72,7 @@ export function CreateStep1Screen() {
setPubkey(pubkey); setPubkey(pubkey);
// save to database // save to database
db.createAccount(npub, pubkey); await db.createAccount(npub, pubkey);
// redirect to next step // redirect to next step
navigate('/auth/create/step-2', { replace: true }); navigate('/auth/create/step-2', { replace: true });
@ -72,76 +85,68 @@ export function CreateStep1Screen() {
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 border-b border-white/10 pb-4">
<h1 className="text-xl font-semibold text-white">Save your access key!</h1> <h1 className="mb-2 text-center text-2xl font-semibold text-white">
This is your new Nostr account
</h1>
<p className="mb-2 text-white/70">
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. There is no way to reset your
private key.
</p>
<p className="text-white/70">
Public key is used for sharing with other people so that they can find you using
the public key.
</p>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-8">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-3">
<span className="text-base font-semibold text-white/50">Public Key</span> <div className="flex flex-col gap-1">
<input <span className="font-medium text-white">Private Key</span>
readOnly <div className="relative">
value={npub} <input
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50" readOnly
/> value={nsec.substring(0, 5) + '**************************************'}
</div> className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 py-1 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
<div className="flex flex-col gap-1"> />
<span className="text-base font-semibold text-white/50">Private Key</span> <button
<div className="relative"> 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-white/20 px-2.5 text-sm hover:bg-white/30"
>
<CopyIcon className="h-4 w-4 text-white/70 group-hover:text-white" />
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium text-white">Public Key</span>
<input <input
readOnly readOnly
type={privkeyInput} value={npub}
value={nsec} className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
className="relative h-11 w-full rounded-lg bg-white/10 py-1 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
/> />
<button
type="button"
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/10"
>
{privkeyInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<div className="mt-2 text-sm text-white/50">
<p>
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. There is no way
to reset your private key.
</p>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<button <button
type="button" type="button"
onClick={() => submit()} onClick={() => download()}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none" className="inline-flex h-12 w-full items-center justify-center rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
> >
{loading ? ( {downloaded ? 'Downloaded' : 'Download account keys'}
<>
<span className="w-5" />
<span>Creating...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>I have saved my key, continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button> </button>
{downloaded ? ( <button
<span className="inline-flex h-11 w-full items-center justify-center text-sm text-white/50"> type="button"
Saved in Download folder onClick={() => submit()}
</span> 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 hover:bg-white/30 focus:outline-none"
) : ( >
<Button preset="large-alt" onClick={() => download()}> {loading ? 'Creating...' : 'Continue'}
Download </button>
</Button> <span className="text-center text-sm text-white/50">
)} 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>
</div> </div>

View File

@ -86,10 +86,16 @@ export function CreateStep2Screen() {
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 border-b border-white/10 pb-4">
<h1 className="text-xl font-semibold text-white"> <h1 className="mb-2 text-center text-2xl font-semibold text-white">
Set password to secure your key Set password to secure your key
</h1> </h1>
<p className="text-white/70">
Password is not related to your Nostr account. It is only used to secure your
keys stored on your local machine and to unlock the app (like unlocking your
phone with a passcode). When you move to other Nostr clients, you just need to
copy your private key.
</p>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
@ -98,12 +104,13 @@ export function CreateStep2Screen() {
<input <input
{...register('password', { required: true })} {...register('password', { required: true })}
type={passwordInput} type={passwordInput}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-center text-white !outline-none backdrop-blur-xl placeholder:text-white/50" placeholder="Enter password"
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-center tracking-widest text-white !outline-none backdrop-blur-xl placeholder:tracking-normal placeholder:text-white/70"
/> />
<button <button
type="button" type="button"
onClick={() => showPassword()} onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/10" className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/20"
> >
{passwordInput === 'password' ? ( {passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" /> <EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
@ -112,13 +119,6 @@ export function CreateStep2Screen() {
)} )}
</button> </button>
</div> </div>
<div className="text-sm text-white/50">
<p>
Password is use to secure your key store in local machine, when you move
to other clients, you just need to copy your private key as nsec or
hexstring
</p>
</div>
<span className="text-sm text-red-400"> <span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>} {errors.password && <p>{errors.password.message}</p>}
</span> </span>
@ -127,12 +127,12 @@ export function CreateStep2Screen() {
<button <button
type="submit" type="submit"
disabled={!isDirty || !isValid} disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none" className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
> >
{loading ? ( {loading ? (
<> <>
<span className="w-5" /> <span className="w-5" />
<span>Creating...</span> <span>Securing your account...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" /> <LoaderIcon className="h-5 w-5 animate-spin text-white" />
</> </>
) : ( ) : (

View File

@ -3,6 +3,8 @@ import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader'; import { AvatarUploader } from '@shared/avatarUploader';
import { BannerUploader } from '@shared/bannerUploader'; import { BannerUploader } from '@shared/bannerUploader';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
@ -10,6 +12,7 @@ import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { Image } from '@shared/image'; import { Image } from '@shared/image';
import { useOnboarding } from '@stores/onboarding'; import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
@ -21,6 +24,7 @@ export function CreateStep3Screen() {
const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih'); const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
const [banner, setBanner] = useState(''); const [banner, setBanner] = useState('');
const { db } = useStorage();
const { publish } = useNostr(); const { publish } = useNostr();
const { const {
register, register,
@ -45,6 +49,9 @@ export function CreateStep3Screen() {
tags: [], tags: [],
}); });
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
if (event) { if (event) {
navigate('/auth/onboarding', { replace: true }); navigate('/auth/onboarding', { replace: true });
} }
@ -61,15 +68,22 @@ export function CreateStep3Screen() {
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 border-b border-white/10 pb-4">
<h1 className="text-xl font-semibold text-white">Create your profile</h1> <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>
<div className="w-full overflow-hidden rounded-xl bg-white/10 backdrop-blur-xl"> <div className="w-full overflow-hidden rounded-xl bg-white/10 backdrop-blur-xl">
<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} />
<input type={'hidden'} {...register('banner')} value={banner} /> <input type={'hidden'} {...register('banner')} value={banner} />
<div className="relative"> <div className="relative">
<div className="relative h-44 w-full bg-white/10 backdrop-blur-xl"> <div className="relative h-36 w-full bg-white/10 backdrop-blur-xl">
{banner ? ( {banner ? (
<Image <Image
src={banner} src={banner}
@ -77,18 +91,18 @@ export function CreateStep3Screen() {
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
) : ( ) : (
<div className="h-full w-full bg-black/50" /> <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"> <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} /> <BannerUploader setBanner={setBanner} />
</div> </div>
</div> </div>
<div className="mb-5 px-4"> <div className="mb-5 px-4">
<div className="relative z-10 -mt-7 h-14 w-14"> <div className="relative z-10 -mt-8 h-16 w-16">
<Image <Image
src={picture} src={picture}
alt="user's avatar" alt="user's avatar"
className="h-14 w-14 rounded-lg object-cover ring-2 ring-white/10" 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"> <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} /> <AvatarUploader setPicture={setPicture} />
@ -98,55 +112,45 @@ export function CreateStep3Screen() {
</div> </div>
<div className="flex flex-col gap-4 px-4 pb-4"> <div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label <label htmlFor="name" className="font-medium text-white">
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
Name * Name *
</label> </label>
<input <input
type={'text'} type={'text'}
{...register('name', { {...register('name', {
required: true, required: true,
minLength: 4, minLength: 1,
})} })}
spellCheck={false} spellCheck={false}
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50" 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>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label <label htmlFor="about" className="font-medium text-white">
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
Bio Bio
</label> </label>
<textarea <textarea
{...register('about')} {...register('about')}
spellCheck={false} spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50" 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>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label <label htmlFor="website" className="font-medium text-white">
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
Website Website
</label> </label>
<input <input
type={'text'}
{...register('website', { {...register('website', {
required: false, required: false,
})} })}
spellCheck={false} spellCheck={false}
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50" 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>
<button <button
type="submit" type="submit"
disabled={!isDirty || !isValid} disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none" className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
> >
{loading ? ( {loading ? (
<> <>

View File

@ -1,7 +0,0 @@
export function HardResetScreen() {
return (
<div>
<p>hard reset</p>
</div>
);
}

View File

@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons'; import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle'; import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding'; import { useOnboarding } from '@stores/onboarding';
@ -37,6 +37,7 @@ export function ImportStep1Screen() {
const setStep = useOnboarding((state) => state.setStep); const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [passwordInput, setPasswordInput] = useState('password');
const { db } = useStorage(); const { db } = useStorage();
const { const {
@ -64,12 +65,13 @@ export function ImportStep1Screen() {
setPubkey(pubkey); setPubkey(pubkey);
// add account to local database // add account to local database
db.createAccount(npub, pubkey); await db.createAccount(npub, pubkey);
// redirect to step 2 // redirect to step 2 with delay 1.2s
navigate('/auth/import/step-2', { replace: true }); setTimeout(() => navigate('/auth/import/step-2', { replace: true }), 1200);
} }
} catch (error) { } catch (error) {
setLoading(false);
setError('privkey', { setError('privkey', {
type: 'custom', type: 'custom',
message: 'Private key is invalid, please check again', message: 'Private key is invalid, please check again',
@ -77,6 +79,15 @@ export function ImportStep1Screen() {
} }
}; };
// toggle private key
const showPassword = () => {
if (passwordInput === 'password') {
setPasswordInput('text');
} else {
setPasswordInput('password');
}
};
useEffect(() => { useEffect(() => {
// save current step, if user close app and reopen it // save current step, if user close app and reopen it
setStep('/auth/import'); setStep('/auth/import');
@ -84,20 +95,37 @@ export function ImportStep1Screen() {
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 pb-4">
<h1 className="text-xl font-semibold text-white">Import your key</h1> <h1 className="text-center text-2xl font-semibold text-white">
Import your Nostr key
</h1>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3"> <form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-base font-semibold text-white/50">Private key</span> <label htmlFor="privkey" className="font-medium text-white">
<input Insert your nostr private key, in nsec or hex format
{...register('privkey', { required: true, minLength: 32 })} </label>
type={'password'} <div className="relative">
placeholder="nsec or hexstring" <input
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50" {...register('privkey', { required: true, minLength: 32 })}
/> type={passwordInput}
<span className="text-sm text-red-400"> placeholder="nsec1..."
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3 py-1 text-white backdrop-blur-xl placeholder:text-white/70 focus:outline-none"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/20"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<span className="text-sm text-red-500">
{errors.privkey && <p>{errors.privkey.message}</p>} {errors.privkey && <p>{errors.privkey.message}</p>}
</span> </span>
</div> </div>
@ -105,12 +133,12 @@ export function ImportStep1Screen() {
<button <button
type="submit" type="submit"
disabled={!isDirty || !isValid} disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none" className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
> >
{loading ? ( {loading ? (
<> <>
<span className="w-5" /> <span className="w-5" />
<span>Creating...</span> <span>Importing...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" /> <LoaderIcon className="h-5 w-5 animate-spin text-white" />
</> </>
) : ( ) : (

View File

@ -86,10 +86,16 @@ export function ImportStep2Screen() {
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 border-b border-white/10 pb-4">
<h1 className="text-xl font-semibold text-white"> <h1 className="mb-2 text-center text-2xl font-semibold text-white">
Set password to secure your key Set password to secure your key
</h1> </h1>
<p className="text-white/70">
Password is not related to your Nostr account. It is only used to secure your
keys stored on your local machine and to unlock the app (like unlocking your
phone with a passcode). When you move to other Nostr clients, you just need to
copy your private key.
</p>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
@ -98,12 +104,13 @@ export function ImportStep2Screen() {
<input <input
{...register('password', { required: true })} {...register('password', { required: true })}
type={passwordInput} type={passwordInput}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-center text-white !outline-none backdrop-blur-xl placeholder:text-white/50" placeholder="Enter password"
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-center tracking-widest text-white !outline-none backdrop-blur-xl placeholder:tracking-normal placeholder:text-white/70"
/> />
<button <button
type="button" type="button"
onClick={() => showPassword()} onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/10" className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/20"
> >
{passwordInput === 'password' ? ( {passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" /> <EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
@ -112,11 +119,6 @@ export function ImportStep2Screen() {
)} )}
</button> </button>
</div> </div>
<p className="text-sm text-white/50">
Password is use to unlock app and secure your key store in local machine.
When you move to other clients, you just need to copy your private key as
nsec or hexstring
</p>
<span className="text-sm text-red-400"> <span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>} {errors.password && <p>{errors.password.message}</p>}
</span> </span>
@ -125,12 +127,12 @@ export function ImportStep2Screen() {
<button <button
type="submit" type="submit"
disabled={!isDirty || !isValid} disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none" className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
> >
{loading ? ( {loading ? (
<> <>
<span className="w-5" /> <span className="w-5" />
<span>Creating...</span> <span>Securing your account...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" /> <LoaderIcon className="h-5 w-5 animate-spin text-white" />
</> </>
) : ( ) : (

View File

@ -1,13 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user'; import { UserImport } from '@app/auth/components/userImport';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding'; import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
@ -15,11 +16,11 @@ export function ImportStep3Screen() {
const navigate = useNavigate(); const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep); const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
const { db } = useStorage(); const { db } = useStorage();
const { fetchUserData, prefetchEvents } = useNostr(); const { fetchUserData, prefetchEvents } = useNostr();
const [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
try { try {
// show loading indicator // show loading indicator
@ -29,6 +30,9 @@ export function ImportStep3Screen() {
const user = await fetchUserData(); const user = await fetchUserData();
const data = await prefetchEvents(); const data = await prefetchEvents();
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
// redirect to next step // redirect to next step
if (user.status === 'ok' && data.status === 'ok') { if (user.status === 'ok' && data.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true }); navigate('/auth/onboarding/step-2', { replace: true });
@ -49,17 +53,19 @@ export function ImportStep3Screen() {
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 pb-4">
<h1 className="text-xl font-semibold"> <h1 className="text-center text-2xl font-semibold text-white">
{loading ? 'Prefetching data...' : 'Continue with'} {loading ? 'Downloading...' : 'Your Nostr profile'}
</h1> </h1>
</div> </div>
<div className="w-full rounded-xl bg-white/10 p-4 backdrop-blur-xl"> <div className="flex flex-col gap-3">
<div className="flex flex-col gap-3"> <div className="rounded-lg border-t border-white/10 bg-white/20 px-3 py-3">
<User pubkey={db.account.pubkey} /> <UserImport pubkey={db.account.pubkey} />
</div>
<div className="flex flex-col gap-2">
<button <button
type="button" type="button"
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none" className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
onClick={() => submit()} onClick={() => submit()}
> >
{loading ? ( {loading ? (
@ -76,6 +82,10 @@ export function ImportStep3Screen() {
</> </>
)} )}
</button> </button>
<span className="text-center text-sm text-white/50">
By clicking &apos;Continue&apos;, Lume will download your relay list and all
events from the last 24 hours. It may take a bit
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -19,7 +19,7 @@ export function OnboardStep1Screen() {
const { publish, fetchUserData, prefetchEvents } = useNostr(); const { publish, fetchUserData, prefetchEvents } = useNostr();
const { db } = useStorage(); const { db } = useStorage();
const { status, data } = useQuery(['trending-profiles'], async () => { const { status, data } = useQuery(['trending-profiles-widget'], async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles'); const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) { if (!res.ok) {
throw new Error('Error'); throw new Error('Error');
@ -68,45 +68,47 @@ export function OnboardStep1Screen() {
}, []); }, []);
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="flex h-full w-full flex-col justify-center">
<div className="mb-8 text-center"> <div className="mx-auto mb-4 w-full max-w-md border-b border-white/10 pb-4">
<h1 className="text-xl font-semibold text-white"> <h1 className="mb-2 text-center text-2xl font-semibold text-white">
{loading ? 'Prefetching data...' : 'Enrich your network'} {loading ? 'Prefetching data...' : 'Enrich your network'}
</h1> </h1>
<p className="text-sm text-white/50">Choose account you want to follow</p> <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>
<div className="flex flex-col gap-4"> <div className="scrollbar-hide flex w-full flex-nowrap items-center gap-4 overflow-x-auto px-4">
<div className="scrollbar-hide flex h-[500px] w-full flex-col overflow-y-auto rounded-xl bg-white/10 py-2 backdrop-blur-xl"> {status === 'loading' ? (
{status === 'loading' ? ( <div className="flex h-full w-full items-center justify-center">
<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-white" /> </div>
</div> ) : (
) : ( data?.profiles.map((item: { pubkey: string; profile: { content: string } }) => (
data?.profiles.map( <button
(item: { pubkey: string; profile: { content: string } }) => ( key={item.pubkey}
<button type="button"
key={item.pubkey} onClick={() => toggleFollow(item.pubkey)}
type="button" 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"
onClick={() => toggleFollow(item.pubkey)} >
className="inline-flex transform items-center justify-between px-4 py-2 hover:bg-white/20" <User pubkey={item.pubkey} fallback={item.profile?.content} />
> {follows.includes(item.pubkey) && (
<User pubkey={item.pubkey} fallback={item.profile?.content} /> <div className="absolute right-2 top-2">
{follows.includes(item.pubkey) && ( <CheckCircleIcon className="h-4 w-4 text-green-400" />
<div> </div>
<CheckCircleIcon className="h-4 w-4 text-green-400" /> )}
</div> </button>
)} ))
</button> )}
) </div>
) <div className="mx-auto mt-4 w-full max-w-md">
)}
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<button <button
type="button" type="button"
onClick={submit} onClick={submit}
disabled={loading || follows.length === 0} disabled={loading || follows.length === 0}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50" className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
> >
{loading ? ( {loading ? (
<> <>
@ -122,12 +124,19 @@ export function OnboardStep1Screen() {
</> </>
)} )}
</button> </button>
<Link {!loading ? (
to="/auth/onboarding/step-2" <Link
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/10 focus:outline-none" 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> Skip, you can add later
</Link>
) : (
<span className="text-center text-sm text-white/50">
By clicking &apos;Continue&apos;, Lume will download all events related to
your follows from the last 24 hours. It may take a bit
</span>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
import { message } from '@tauri-apps/api/dialog'; import { message } from '@tauri-apps/api/dialog';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
@ -12,6 +12,7 @@ import { WidgetKinds } from '@stores/widgets';
const data = [ const data = [
{ hashtag: '#bitcoin' }, { hashtag: '#bitcoin' },
{ hashtag: '#nostr' }, { hashtag: '#nostr' },
{ hashtag: '#nostrdesign' },
{ hashtag: '#zap' }, { hashtag: '#zap' },
{ hashtag: '#LFG' }, { hashtag: '#LFG' },
{ hashtag: '#zapchain' }, { hashtag: '#zapchain' },
@ -20,6 +21,10 @@ const data = [
{ hashtag: '#hodl' }, { hashtag: '#hodl' },
{ hashtag: '#stacksats' }, { hashtag: '#stacksats' },
{ hashtag: '#nokyc' }, { hashtag: '#nokyc' },
{ hashtag: '#meme' },
{ hashtag: '#memes' },
{ hashtag: '#memestr' },
{ hashtag: '#penisbutter' },
{ hashtag: '#anime' }, { hashtag: '#anime' },
{ hashtag: '#waifu' }, { hashtag: '#waifu' },
{ hashtag: '#manga' }, { hashtag: '#manga' },
@ -29,8 +34,8 @@ const data = [
export function OnboardStep2Screen() { export function OnboardStep2Screen() {
const navigate = useNavigate(); const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const [setStep, clearStep] = useOnboarding((state) => [state.setStep, state.clearStep]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [tags, setTags] = useState(new Set<string>()); const [tags, setTags] = useState(new Set<string>());
@ -48,6 +53,16 @@ export function OnboardStep2Screen() {
} }
}; };
const skip = async () => {
// update last login
await db.updateLastLogin();
// clear local storage
clearStep();
navigate('/auth/complete', { replace: true });
};
const submit = async () => { const submit = async () => {
try { try {
setLoading(true); setLoading(true);
@ -56,9 +71,16 @@ export function OnboardStep2Screen() {
await db.createWidget(WidgetKinds.global.hashtag, tag, tag.replace('#', '')); await db.createWidget(WidgetKinds.global.hashtag, tag, tag.replace('#', ''));
} }
navigate('/auth/onboarding/step-3', { replace: true }); // update last login
await db.updateLastLogin();
// clear local storage
clearStep();
navigate('/auth/complete', { replace: true });
} catch (e) { } catch (e) {
await message(e, { title: 'Error', type: 'error' }); setLoading(false);
await message(e, { title: 'Lume', type: 'error' });
} }
}; };
@ -69,20 +91,23 @@ export function OnboardStep2Screen() {
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-4 border-b border-white/10 pb-4">
<h1 className="text-xl font-semibold text-white"> <h1 className="mb-2 text-center text-2xl font-semibold text-white">
Choose {tags.size}/3 your favorite tags Choose {tags.size}/3 your favorite hashtags
</h1> </h1>
<p className="text-sm text-white/50">Customize your space which hashtag widget</p> <p className="text-white/70">
Hashtags are an easy way to discover more content. By adding a hashtag, Lume
will show all related posts. You can always add more later.
</p>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="scrollbar-hide flex h-[500px] w-full flex-col overflow-y-auto rounded-xl bg-white/10 backdrop-blur-xl"> <div className="scrollbar-hide flex h-[450px] w-full flex-col divide-y divide-white/5 overflow-y-auto rounded-xl bg-white/20 backdrop-blur-xl">
{data.map((item: { hashtag: string }) => ( {data.map((item: { hashtag: string }) => (
<button <button
key={item.hashtag} key={item.hashtag}
type="button" type="button"
onClick={() => toggleTag(item.hashtag)} onClick={() => toggleTag(item.hashtag)}
className="inline-flex transform items-center justify-between bg-white/10 px-4 py-2 backdrop-blur-xl hover:bg-white/20" className="inline-flex transform items-center justify-between px-4 py-2 hover:bg-white/10"
> >
<p className="text-white">{item.hashtag}</p> <p className="text-white">{item.hashtag}</p>
{tags.has(item.hashtag) && ( {tags.has(item.hashtag) && (
@ -98,7 +123,7 @@ export function OnboardStep2Screen() {
type="button" type="button"
onClick={submit} onClick={submit}
disabled={loading || tags.size === 0 || tags.size > 3} disabled={loading || tags.size === 0 || tags.size > 3}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50" className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
> >
{loading ? ( {loading ? (
<> <>
@ -114,12 +139,15 @@ export function OnboardStep2Screen() {
</> </>
)} )}
</button> </button>
<Link {!loading ? (
to="/auth/onboarding/step-3" <button
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/10 focus:outline-none" type="button"
> onClick={() => skip()}
Skip, you can add later className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
</Link> >
Skip, you can add later
</button>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@ -4,7 +4,7 @@ import { Resolver, useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api'; import { Stronghold } from 'tauri-plugin-stronghold-api';
import { User } from '@app/auth/components/user'; import { UserImport } from '@app/auth/components/userImport';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
@ -74,20 +74,22 @@ export function UnlockScreen() {
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-6 text-center"> <div className="mb-4 pb-4">
<h1 className="text-2xl font-semibold text-white">Enter password to unlock</h1> <h1 className="text-center text-2xl font-semibold text-white">
Enter password to unlock
</h1>
</div> </div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col"> <form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<div className="flex flex-col rounded-lg bg-white/5"> <div className="flex flex-col rounded-lg bg-white/5">
<div className="w-full rounded-t-lg border-b border-white/10 bg-white/5 p-4"> <div className="w-full rounded-t-lg border-b border-white/10 bg-white/5 p-4">
<User pubkey={db.account.pubkey} /> <UserImport pubkey={db.account.pubkey} />
</div> </div>
<div className="relative"> <div className="relative">
<input <input
{...register('password', { required: true, minLength: 4 })} {...register('password', { required: true, minLength: 4 })}
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
placeholder="Password" placeholder="Password"
className="relative h-12 w-full rounded-b-lg bg-white/10 py-1 text-center text-white !outline-none backdrop-blur-xl placeholder:text-white/50" className="relative h-12 w-full rounded-b-lg bg-white/10 py-1 text-center tracking-widest text-white !outline-none backdrop-blur-xl placeholder:tracking-normal placeholder:text-white/50"
/> />
<button <button
type="button" type="button"
@ -109,12 +111,12 @@ export function UnlockScreen() {
<button <button
type="submit" type="submit"
disabled={!isDirty || !isValid} disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50" className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
> >
{loading ? ( {loading ? (
<> <>
<span className="w-5" /> <span className="w-5" />
<span>Decryting...</span> <span>Unlocking...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" /> <LoaderIcon className="h-5 w-5 animate-spin text-white" />
</> </>
) : ( ) : (
@ -127,7 +129,7 @@ export function UnlockScreen() {
</button> </button>
<Link <Link
to="/auth/reset" to="/auth/reset"
className="mt-1 inline-flex h-11 w-full items-center justify-center rounded-lg text-center text-white/50 hover:bg-white/10" className="mt-1 inline-flex h-12 w-full items-center justify-center rounded-lg text-center text-white/70 hover:bg-white/20"
> >
Reset password Reset password
</Link> </Link>

View File

@ -2,7 +2,6 @@ import { LogicalSize, getCurrent } from '@tauri-apps/api/window';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Frame } from '@shared/frame';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle'; import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
export function WelcomeScreen() { export function WelcomeScreen() {
@ -29,7 +28,7 @@ export function WelcomeScreen() {
}, []); }, []);
return ( return (
<Frame className="flex h-screen w-full flex-col justify-between"> <div className="flex h-screen w-full flex-col justify-between">
<div className="flex flex-col gap-10 pt-16"> <div className="flex flex-col gap-10 pt-16">
<div className="flex flex-col gap-1.5 text-center"> <div className="flex flex-col gap-1.5 text-center">
<h1 className="text-3xl font-semibold text-white">Welcome to Lume</h1> <h1 className="text-3xl font-semibold text-white">Welcome to Lume</h1>
@ -38,10 +37,10 @@ export function WelcomeScreen() {
Nostr Nostr
</p> </p>
</div> </div>
<div className="inline-flex w-full flex-col items-center gap-2 px-4 pb-10"> <div className="inline-flex w-full flex-col items-center gap-3 px-4 pb-10">
<Link <Link
to="/auth/import" to="/auth/import"
className="inline-flex h-11 w-2/3 items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none" className="inline-flex h-12 w-3/4 items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-4 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
> >
<span className="w-5" /> <span className="w-5" />
<span>Login with private key</span> <span>Login with private key</span>
@ -49,15 +48,15 @@ export function WelcomeScreen() {
</Link> </Link>
<Link <Link
to="/auth/create" to="/auth/create"
className="inline-flex h-11 w-2/3 items-center justify-center gap-2 rounded-lg bg-white/10 px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/20 focus:outline-none" className="inline-flex h-12 w-3/4 items-center justify-center gap-2 rounded-lg border-t border-white/10 bg-white/20 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
> >
Create new key Create new key
</Link> </Link>
</div> </div>
</div> </div>
<div className="flex flex-1 items-end justify-center pb-10"> <div className="flex flex-1 items-end justify-center pb-6">
<img src="/lume.png" alt="lume" className="h-auto w-1/3" /> <img src="/lume.png" alt="lume" className="h-auto w-1/4" />
</div> </div>
</Frame> </div>
); );
} }

View File

@ -27,7 +27,7 @@ export function ChatsListItem({ pubkey }: { pubkey: string }) {
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2', 'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
isActive isActive
? 'border-fuchsia-500 bg-white/5 text-white' ? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/80' : 'border-transparent text-white/70'
) )
} }
> >

View File

@ -1,7 +1,7 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { writeText } from '@tauri-apps/api/clipboard'; import { writeText } from '@tauri-apps/api/clipboard';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/nip19'; import { AddressPointer, EventPointer } from 'nostr-tools/lib/nip19';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
@ -27,13 +27,15 @@ export function ArticleNoteScreen() {
const { id } = useParams(); const { id } = useParams();
const { db } = useStorage(); const { db } = useStorage();
const { status, data } = useEvent(id);
const naddr = id.startsWith('naddr') ? (nip19.decode(id).data as AddressPointer) : null;
const { status, data } = useEvent(id, naddr);
const [isCopy, setIsCopy] = useState(false); const [isCopy, setIsCopy] = useState(false);
const share = async () => { const share = async () => {
await writeText( await writeText(
'https://nostr.com/' + 'https://njump.me/' +
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer) nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
); );
// update state // update state
@ -103,15 +105,15 @@ export function ArticleNoteScreen() {
<ThreadUser pubkey={data.pubkey} time={data.created_at} /> <ThreadUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-2">{renderKind(data)}</div> <div className="mt-2">{renderKind(data)}</div>
<div> <div>
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} /> <NoteActions id={data.id} pubkey={data.pubkey} extraButtons={false} />
<NoteStats id={id} /> <NoteStats id={data.id} />
</div> </div>
</div> </div>
</div> </div>
)} )}
<div ref={replyRef} className="px-3"> <div ref={replyRef} className="px-3">
<NoteReplyForm id={id} pubkey={db.account.pubkey} /> <NoteReplyForm id={data?.id} pubkey={db.account.pubkey} />
<RepliesList id={id} /> <RepliesList id={data?.id} />
</div> </div>
</div> </div>
<div className="col-span-1" /> <div className="col-span-1" />

View File

@ -35,7 +35,7 @@ export function TextNoteScreen() {
const share = async () => { const share = async () => {
await writeText( await writeText(
'https://nostr.com/' + 'https://njump.me/' +
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer) nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
); );
// update state // update state

View File

@ -8,6 +8,7 @@ import {
FollowsIcon, FollowsIcon,
GroupFeedsIcon, GroupFeedsIcon,
HashtagIcon, HashtagIcon,
ThreadsIcon,
TrendingIcon, TrendingIcon,
} from '@shared/icons'; } from '@shared/icons';
import { TitleBar } from '@shared/titleBar'; import { TitleBar } from '@shared/titleBar';
@ -46,8 +47,10 @@ export function WidgetList({ params }: { params: Widget }) {
case WidgetKinds.nostrBand.trendingAccounts: case WidgetKinds.nostrBand.trendingAccounts:
case WidgetKinds.nostrBand.trendingNotes: case WidgetKinds.nostrBand.trendingNotes:
return <TrendingIcon className="h-5 w-4 text-white" />; return <TrendingIcon className="h-5 w-4 text-white" />;
case WidgetKinds.other.learnNostr:
return <ThreadsIcon className="h-5 w-4 text-white" />;
default: default:
return ''; return null;
} }
}, },
[DefaultWidgets] [DefaultWidgets]
@ -94,25 +97,25 @@ export function WidgetList({ params }: { params: Widget }) {
); );
return ( return (
<div className="relative h-full shrink-0 grow-0 basis-[400px] overflow-hidden bg-white/10"> <div className="relative h-full shrink-0 grow-0 basis-[400px] bg-white/10">
<TitleBar id={params.id} title="Add widget" /> <TitleBar id={params.id} title="Add widget" />
<div className="flex flex-col gap-6 px-3"> <div className="scrollbar-hide h-full overflow-y-auto pb-20">
{DefaultWidgets.map((row: WidgetGroup) => renderItem(row))} <div className="flex flex-col gap-6 px-3">
</div> {DefaultWidgets.map((row: WidgetGroup) => renderItem(row))}
<div className="mt-6 px-3"> <div className="border-t border-white/5 pt-6">
<div className="border-t border-white/5 pt-6"> <button
<button type="button"
type="button" disabled
disabled className="inline-flex h-14 w-full items-center justify-center gap-2.5 rounded-xl bg-white/5 text-sm font-medium text-white/50"
className="inline-flex h-14 w-full items-center justify-center gap-2.5 rounded-xl bg-white/5 text-sm font-medium text-white/50" >
> Build your own widget{' '}
Build your own widget{' '} <div className="-rotate-3 transform rounded-md border border-white/20 bg-white/10 px-1.5 py-1">
<div className="-rotate-3 transform rounded-md border border-white/20 bg-white/10 px-1.5 py-1"> <span className="bg-gradient-to-t from-fuchsia-200 via-red-200 to-orange-300 bg-clip-text text-xs text-transparent">
<span className="bg-gradient-to-t from-fuchsia-200 via-red-200 to-orange-300 bg-clip-text text-xs text-transparent"> Coming soon
Coming soon </span>
</span> </div>
</div> </button>
</button> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -10,6 +10,7 @@ import {
GlobalArticlesWidget, GlobalArticlesWidget,
GlobalFilesWidget, GlobalFilesWidget,
GlobalHashtagWidget, GlobalHashtagWidget,
LearnNostrWidget,
LocalArticlesWidget, LocalArticlesWidget,
LocalFeedsWidget, LocalFeedsWidget,
LocalFilesWidget, LocalFilesWidget,
@ -69,8 +70,10 @@ export function SpaceScreen() {
return <XfeedsWidget key={widget.id} params={widget} />; return <XfeedsWidget key={widget.id} params={widget} />;
case WidgetKinds.tmp.list: case WidgetKinds.tmp.list:
return <WidgetList key={widget.id} params={widget} />; return <WidgetList key={widget.id} params={widget} />;
case WidgetKinds.other.learnNostr:
return <LearnNostrWidget key={widget.id} params={widget} />;
default: default:
break; return null;
} }
}, },
[widgets] [widgets]

View File

@ -19,7 +19,7 @@ export function AccountMoreActions({ pubkey }: { pubkey: string }) {
</button> </button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl border border-white/10 bg-white/10 p-2 backdrop-blur-3xl focus:outline-none"> <DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl border border-white/10 bg-white/20 p-2 backdrop-blur-3xl focus:outline-none">
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<Link <Link
to={`/users/${pubkey}`} to={`/users/${pubkey}`}

View File

@ -2,14 +2,14 @@ import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons'; import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader'; import { useNostr } from '@utils/hooks/useNostr';
export function AvatarUploader({ export function AvatarUploader({
setPicture, setPicture,
}: { }: {
setPicture: Dispatch<SetStateAction<string>>; setPicture: Dispatch<SetStateAction<string>>;
}) { }) {
const upload = useImageUploader(); const { upload } = useNostr();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadAvatar = async () => { const uploadAvatar = async () => {

View File

@ -2,14 +2,14 @@ import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons'; import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader'; import { useNostr } from '@utils/hooks/useNostr';
export function BannerUploader({ export function BannerUploader({
setBanner, setBanner,
}: { }: {
setBanner: Dispatch<SetStateAction<string>>; setBanner: Dispatch<SetStateAction<string>>;
}) { }) {
const upload = useImageUploader(); const { upload } = useNostr();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadBanner = async () => { const uploadBanner = async () => {
@ -25,13 +25,14 @@ export function BannerUploader({
<button <button
type="button" type="button"
onClick={() => uploadBanner()} onClick={() => uploadBanner()}
className="inline-flex h-full w-full items-center justify-center bg-black/40 hover:bg-black/50" className="inline-flex h-full w-full flex-col items-center justify-center gap-1 bg-black/40 hover:bg-black/50"
> >
{loading ? ( {loading ? (
<LoaderIcon className="h-8 w-8 animate-spin text-white" /> <LoaderIcon className="h-6 w-6 animate-spin text-white" />
) : ( ) : (
<PlusIcon className="h-8 w-8 text-white" /> <PlusIcon className="h-6 w-6 text-white" />
)} )}
<p className="text-sm font-medium text-white/70">Add a banner image</p>
</button> </button>
); );
} }

View File

@ -42,7 +42,7 @@ export function Navigation() {
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2', 'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
isActive isActive
? 'border-fuchsia-500 bg-white/5 text-white' ? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/80' : 'border-transparent text-white/70'
) )
} }
> >
@ -59,7 +59,7 @@ export function Navigation() {
'flex h-10 items-center justify-between rounded-r-lg border-l-2 pl-4 pr-2', 'flex h-10 items-center justify-between rounded-r-lg border-l-2 pl-4 pr-2',
isActive isActive
? 'border-fuchsia-500 bg-white/5 text-white' ? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/80' : 'border-transparent text-white/70'
) )
} }
> >
@ -104,7 +104,7 @@ export function Navigation() {
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2', 'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
isActive isActive
? 'border-fuchsia-500 bg-white/5 text-white' ? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/80' : 'border-transparent text-white/70'
) )
} }
> >

View File

@ -18,8 +18,7 @@ export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
const copyLink = async () => { const copyLink = async () => {
await writeText( await writeText(
'https://nostr.com/' + 'https://njump.me/' + nip19.neventEncode({ id: id, author: pubkey } as EventPointer)
nip19.neventEncode({ id: id, author: pubkey } as EventPointer)
); );
setOpen(false); setOpen(false);
}; };

View File

@ -43,7 +43,7 @@ export function ChildNote({ id, root }: { id: string; root?: string }) {
} }
if (status === 'error') { if (status === 'error') {
const noteLink = `https://nostr.com/${nip19.noteEncode(id)}`; const noteLink = `https://njump.me/${nip19.noteEncode(id)}`;
return ( return (
<> <>
<div className="absolute bottom-0 left-[18px] h-[calc(100%-3.4rem)] w-0.5 bg-gradient-to-t from-white/20 to-white/10" /> <div className="absolute bottom-0 left-[18px] h-[calc(100%-3.4rem)] w-0.5 bg-gradient-to-t from-white/20 to-white/10" />
@ -62,7 +62,7 @@ export function ChildNote({ id, root }: { id: string; root?: string }) {
<div className="relative z-20 mt-1 flex-1 select-text"> <div className="relative z-20 mt-1 flex-1 select-text">
<div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm"> <div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm">
Lume cannot find this post with your current relays, but you can view it Lume cannot find this post with your current relays, but you can view it
via nostr.com.{' '} via njump.me.{' '}
<Link to={noteLink} className="text-fuchsia-500"> <Link to={noteLink} className="text-fuchsia-500">
Learn more Learn more
</Link> </Link>

View File

@ -19,7 +19,7 @@ import { useEvent } from '@utils/hooks/useEvent';
export function Repost({ event, root }: { event: NDKEvent; root?: string }) { export function Repost({ event, root }: { event: NDKEvent; root?: string }) {
const rootPost = root ?? event.tags.find((el) => el[0] === 'e')?.[1]; const rootPost = root ?? event.tags.find((el) => el[0] === 'e')?.[1];
const { status, data } = useEvent(rootPost, event.content); const { status, data } = useEvent(rootPost, null, event.content);
const renderKind = useCallback( const renderKind = useCallback(
(repostEvent: NDKEvent) => { (repostEvent: NDKEvent) => {
@ -49,7 +49,7 @@ export function Repost({ event, root }: { event: NDKEvent; root?: string }) {
if (status === 'error') { if (status === 'error') {
// @ts-expect-error, root_id isn't exist on NDKEvent // @ts-expect-error, root_id isn't exist on NDKEvent
const noteLink = `https://nostr.com/${nip19.noteEncode(event.root_id)}`; const noteLink = `https://njump.me/${nip19.noteEncode(event.root_id)}`;
return ( return (
<div className="relative mb-5 flex flex-col"> <div className="relative mb-5 flex flex-col">
<div className="relative z-10 flex items-start gap-3"> <div className="relative z-10 flex items-start gap-3">
@ -66,7 +66,7 @@ export function Repost({ event, root }: { event: NDKEvent; root?: string }) {
<div className="relative z-20 mt-1 flex-1 select-text"> <div className="relative z-20 mt-1 flex-1 select-text">
<div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm"> <div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm">
Lume cannot find this post with your current relays, but you can view it Lume cannot find this post with your current relays, but you can view it
via nostr.com.{' '} via njump.me.{' '}
<Link to={noteLink} className="text-fuchsia-500"> <Link to={noteLink} className="text-fuchsia-500">
Learn more Learn more
</Link> </Link>

View File

@ -56,7 +56,7 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
} }
if (status === 'error') { if (status === 'error') {
const noteLink = `https://nostr.com/${nip19.noteEncode(id)}`; const noteLink = `https://njump.me/${nip19.noteEncode(id)}`;
return ( return (
<div className="relative mt-3 flex flex-col"> <div className="relative mt-3 flex flex-col">
<div className="relative z-10 flex items-center gap-3"> <div className="relative z-10 flex items-center gap-3">
@ -70,7 +70,7 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
<div className="mt-1"> <div className="mt-1">
<div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm"> <div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm">
Lume cannot find this post with your current relays, but you can view it via Lume cannot find this post with your current relays, but you can view it via
nostr.com.{' '} njump.me.{' '}
<Link to={noteLink} className="text-fuchsia-500"> <Link to={noteLink} className="text-fuchsia-500">
Learn more Learn more
</Link> </Link>

View File

@ -18,10 +18,10 @@ export function RepostUser({ pubkey }: { pubkey: string }) {
className="relative z-20 inline-block h-6 w-6 rounded bg-white ring-1 ring-black" className="relative z-20 inline-block h-6 w-6 rounded bg-white ring-1 ring-black"
/> />
<div className="inline-flex items-baseline gap-1"> <div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[13rem] truncate text-white/50"> <h5 className="max-w-[13rem] truncate text-sm text-white/50">
{user?.name || user?.display_name || shortenKey(pubkey)} {user?.name || user?.display_name || shortenKey(pubkey)}
</h5> </h5>
<span className="text-white/50">reposted</span> <span className="text-sm text-white/50">reposted</span>
</div> </div>
</div> </div>
); );

View File

@ -11,3 +11,4 @@ export * from './nostrBand/trendingNotes';
export * from './nostrBand/trendingAccounts'; export * from './nostrBand/trendingAccounts';
export * from './tmp/feeds'; export * from './tmp/feeds';
export * from './tmp/hashtag'; export * from './tmp/hashtag';
export * from './other/learnNostr';

View File

@ -0,0 +1,68 @@
import { useNavigate } from 'react-router-dom';
import { ArrowRightIcon } from '@shared/icons';
import { TitleBar } from '@shared/titleBar';
import { useResources } from '@stores/resources';
import { Widget } from '@utils/types';
export function LearnNostrWidget({ params }: { params: Widget }) {
const navigate = useNavigate();
const openResource = useResources((state) => state.openResource);
const resources = useResources((state) => state.resources);
const seens = useResources((state) => state.seens);
const open = (naddr: string) => {
// add resource to seen list
openResource(naddr);
// redirect
navigate(`/notes/article/${naddr}`);
};
return (
<div className="relative shrink-0 grow-0 basis-[400px] bg-white/10 backdrop-blur-xl">
<TitleBar id={params.id} title="The Joy of Nostr" />
<div className="scrollbar-hide h-full overflow-y-auto px-3 pb-20">
{resources.map((resource, index) => (
<div key={index} className="mb-6">
<h3 className="mb-2 font-medium text-white/50">{resource.title}</h3>
<div className="flex flex-col gap-2">
{resource.data.length ? (
resource.data.map((item, index) => (
<button
key={index}
type="button"
onClick={() => open(item.id)}
className="flex items-center justify-between rounded-xl bg-white/10 px-3 py-3 hover:bg-white/20"
>
<div className="inline-flex items-center gap-2.5">
<div className="h-10 w-10 shrink-0 rounded-md bg-white/10" />
<div className="flex flex-col items-start gap-1">
<h5 className="font-semibold leading-none">{item.title}</h5>
{seens.has(item.id) ? (
<p className="text-sm leading-none text-green-500">Readed</p>
) : (
<p className="text-sm leading-none text-white/70">Unread</p>
)}
</div>
</div>
<button type="button">
<ArrowRightIcon className="h-5 w-5 text-white" />
</button>
</button>
))
) : (
<div className="flex h-14 items-center justify-center rounded-xl bg-white/10 px-3 py-3">
<p className="text-sm font-medium text-white">
More resources are coming, stay tuned.
</p>
</div>
)}
</div>
</div>
))}
</div>
</div>
);
}

88
src/stores/resources.ts Normal file
View File

@ -0,0 +1,88 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { Resources } from '@utils/types';
const DEFAULT_RESOURCES: Array<Resources> = [
{
title: 'The Basics (provide by nostr.com)',
data: [
{
id: 'naddr1qqxnzd3exsurgwfnxgcnjve5qgsym7p8qvs805ny3z3vausedzzwnwk27cfe67r69nrxpqe8w0urmegrqsqqqa283wgxe0',
title: 'What is Nostr?',
image: '',
},
{
id: 'naddr1qqxnzd3exsurgwf48qcnvdfcqgsym7p8qvs805ny3z3vausedzzwnwk27cfe67r69nrxpqe8w0urmegrqsqqqa28cnv0yt',
title: 'Understanding keys',
image: '',
},
{
id: 'naddr1qqxnzd3exsurgwfcxgcrzwfjqgsym7p8qvs805ny3z3vausedzzwnwk27cfe67r69nrxpqe8w0urmegrqsqqqa28uccw5e',
title: "What's a client?",
image: '',
},
{
id: 'naddr1qqxnzd3exsurgwfexqersdp5qgsym7p8qvs805ny3z3vausedzzwnwk27cfe67r69nrxpqe8w0urmegrqsqqqa28jvlesq',
title: 'What are relays?',
image: '',
},
{
id: 'naddr1qqxnzd3exsur2vpjxserjveeqgsym7p8qvs805ny3z3vausedzzwnwk27cfe67r69nrxpqe8w0urmegrqsqqqa28rqy7mx',
title: 'What is an event?',
image: '',
},
{
id: 'naddr1qqxnzd3exsur2vp5xsmnywpnqgsym7p8qvs805ny3z3vausedzzwnwk27cfe67r69nrxpqe8w0urmegrqsqqqa28hxwx4e',
title: 'How to help Nostr?',
image: '',
},
],
},
{
title: 'Lume Tutorials',
data: [],
},
];
interface ResourceState {
resources: Array<Resources>;
seens: Set<string>;
openResource: (id: string) => void;
}
export const useResources = create<ResourceState>()(
persist(
(set) => ({
resources: DEFAULT_RESOURCES,
seens: new Set(),
openResource: (id: string) => {
set((state) => ({ seens: new Set(state.seens).add(id) }));
},
}),
{
name: 'resources',
storage: {
getItem: (name) => {
const str = localStorage.getItem(name);
return {
state: {
...JSON.parse(str).state,
seens: new Set(JSON.parse(str).state.seens),
},
};
},
setItem: (name, newValue) => {
const str = JSON.stringify({
state: {
...newValue.state,
seens: Array.from(newValue.state.seens),
},
});
localStorage.setItem(name, str);
},
removeItem: (name) => localStorage.removeItem(name),
},
}
)
);

View File

@ -32,6 +32,9 @@ export const WidgetKinds = {
trendingAccounts: 1, trendingAccounts: 1,
trendingNotes: 2, trendingNotes: 2,
}, },
other: {
learnNostr: 90000,
},
tmp: { tmp: {
list: 10000, list: 10000,
xfeed: 10001, xfeed: 10001,
@ -100,6 +103,16 @@ export const DefaultWidgets: Array<WidgetGroup> = [
}, },
], ],
}, },
{
title: 'Other',
data: [
{
kind: WidgetKinds.other.learnNostr,
title: 'Learn Nostr',
description: 'All things you need to know about Nostr',
},
],
},
]; ];
export const useWidgets = create<WidgetState>()( export const useWidgets = create<WidgetState>()(

View File

@ -1,22 +1,35 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { AddressPointer } from 'nostr-tools/lib/nip19';
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 { toRawEvent } from '@utils/rawEvent'; import { toRawEvent } from '@utils/rawEvent';
export function useEvent(id: string, embed?: string) { export function useEvent(id: string, naddr?: AddressPointer, embed?: string) {
const { db } = useStorage(); const { db } = useStorage();
const { ndk } = useNDK(); const { ndk } = useNDK();
const { status, data } = useQuery( const { status, data } = useQuery(
['event', id], ['event', id],
async () => { async () => {
if (naddr) {
const rEvents = await ndk.fetchEvents({
kinds: [naddr.kind],
'#d': [naddr.identifier],
authors: [naddr.pubkey],
});
const rEvent = [...rEvents].slice(-1)[0];
return rEvent;
}
// return embed event (nostr.band api) or repost // return embed event (nostr.band api) or repost
if (embed) { if (embed) {
const event: NDKEvent = JSON.parse(embed); const event: NDKEvent = JSON.parse(embed);
return event; return event;
} }
// get event from db // get event from db
const dbEvent = await db.getEventByID(id); const dbEvent = await db.getEventByID(id);
if (dbEvent) return dbEvent; if (dbEvent) return dbEvent;

View File

@ -57,6 +57,24 @@ export function useNostr() {
const follows = new Set<string>(preFollows || []); const follows = new Set<string>(preFollows || []);
const lruNetwork = new LRUCache<string, string, void>({ max: 300 }); const lruNetwork = new LRUCache<string, string, void>({ max: 300 });
// fetch user's relays
const relayEvents = await ndk.fetchEvents({
kinds: [NDKKind.RelayList],
authors: [db.account.pubkey],
});
if (relayEvents) {
const latestRelayEvent = [...relayEvents].sort(
(a, b) => b.created_at - a.created_at
)[0];
if (latestRelayEvent) {
for (const item of latestRelayEvent.tags) {
await db.createRelay(item[1], item[2]);
}
}
}
// fetch user's follows // fetch user's follows
if (!preFollows) { if (!preFollows) {
const user = ndk.getUser({ hexpubkey: db.account.pubkey }); const user = ndk.getUser({ hexpubkey: db.account.pubkey });
@ -67,20 +85,22 @@ export function useNostr() {
} }
// build user's network // build user's network
const events = await ndk.fetchEvents({ const followEvents = await ndk.fetchEvents({
kinds: [3], kinds: [NDKKind.Contacts],
authors: [...follows], authors: [...follows],
limit: 300, limit: 300,
}); });
events.forEach((event: NDKEvent) => { followEvents.forEach((event: NDKEvent) => {
event.tags.forEach((tag) => { event.tags.forEach((tag) => {
if (tag[0] === 'p') lruNetwork.set(tag[1], tag[1]); if (tag[0] === 'p') lruNetwork.set(tag[1], tag[1]);
}); });
}); });
// get lru values
const network = [...lruNetwork.values()] as string[]; const network = [...lruNetwork.values()] as string[];
// update db
await db.updateAccount('follows', [...follows]); await db.updateAccount('follows', [...follows]);
await db.updateAccount('network', [...new Set([...follows, ...network])]); await db.updateAccount('network', [...new Set([...follows, ...network])]);

View File

@ -1,90 +0,0 @@
import { magnetDecode } from '@ctrl/magnet-link';
import { open } from '@tauri-apps/api/dialog';
import { VoidApi } from '@void-cat/api';
import { createBlobFromFile } from '@utils/createBlobFromFile';
import { useNostr } from '@utils/hooks/useNostr';
export function useImageUploader() {
const { publish } = useNostr();
const upload = async (file: null | string, nip94?: boolean) => {
const voidcat = new VoidApi('https://void.cat');
let filepath = file;
if (!file) {
const selected = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: [
'png',
'jpeg',
'jpg',
'gif',
'mp4',
'mp3',
'webm',
'mkv',
'avi',
'mov',
],
},
],
});
if (Array.isArray(selected)) {
// user selected multiple files
} else if (selected === null) {
// user cancelled the selection
} else {
filepath = selected;
}
}
const filename = filepath.split('/').pop();
const filetype = filename.split('.').pop();
const blob = await createBlobFromFile(filepath);
const uploader = voidcat.getUploader(blob);
// upload file
const res = await uploader.upload();
if (res.ok) {
const url =
res.file?.metadata?.url ?? `https://void.cat/d/${res.file?.id}.${filetype}`;
if (nip94) {
const tags = [
['url', url],
['x', res.file?.metadata?.digest ?? ''],
['m', res.file?.metadata?.mimeType ?? 'application/octet-stream'],
['size', res.file?.metadata?.size.toString() ?? '0'],
];
if (res.file?.metadata?.magnetLink) {
tags.push(['magnet', res.file.metadata.magnetLink]);
const parsedMagnet = magnetDecode(res.file.metadata.magnetLink);
if (parsedMagnet?.infoHash) {
tags.push(['i', parsedMagnet?.infoHash]);
}
}
await publish({ content: '', kind: 1063, tags: tags });
}
return {
url: url,
error: null,
};
}
return {
url: null,
error: 'Upload failed',
};
};
return upload;
}

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

@ -111,3 +111,14 @@ export interface NostrBuildResponse extends Response {
}>; }>;
}; };
} }
export interface Resource {
id: string;
title: string;
image: string;
}
export interface Resources {
title: string;
data: Array<Resource>;
}