mirror of
https://github.com/luminous-devs/lume.git
synced 2024-09-19 11:43:30 +00:00
Merge pull request #87 from luminous-devs/feat/improve-onboarding
merge now, improve later
This commit is contained in:
commit
c590e290e0
@ -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": {
|
||||||
|
21
src/app.tsx
21
src/app.tsx
@ -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
43
src/app/auth/complete.tsx
Normal 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're ready</span>, redirecting in {count}
|
||||||
|
...
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/70">
|
||||||
|
Thank you for using Lume. Lume doesn't use telemetry. If you encounter any
|
||||||
|
problems, please submit a report via the "Report Issue" 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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">
|
||||||
|
<p className="max-w-[15rem] truncate text-lg font-semibold leading-none text-white">
|
||||||
{user?.name || user?.display_name}
|
{user?.name || user?.display_name}
|
||||||
</p>
|
</p>
|
||||||
<span className="max-w-[15rem] truncate leading-tight text-white/50">
|
<p className="line-clamp-6 break-all text-white/70">
|
||||||
{displayNpub(pubkey, 16)}
|
{user?.about || user?.bio || 'No bio'}
|
||||||
</span>
|
</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>
|
||||||
);
|
);
|
||||||
|
38
src/app/auth/components/userImport.tsx
Normal file
38
src/app/auth/components/userImport.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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 showPrivateKey = () => {
|
|
||||||
if (privkeyInput === 'password') {
|
|
||||||
setPrivkeyInput('text');
|
|
||||||
} else {
|
|
||||||
setPrivkeyInput('password');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const download = async () => {
|
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(
|
await writeTextFile(
|
||||||
`nostr_keys_${new Date().toISOString().slice(0, 10)}.txt`,
|
filePath,
|
||||||
`Generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`,
|
`Generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`
|
||||||
{
|
|
||||||
dir: BaseDirectory.Download,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setDownloaded(true);
|
setDownloaded(true);
|
||||||
|
} // else { user cancel action }
|
||||||
|
} catch (e) {
|
||||||
|
await message(e, { title: 'Cannot download account keys', type: 'error' });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = () => {
|
const copyPrivkey = async () => {
|
||||||
|
try {
|
||||||
|
await writeText(nsec);
|
||||||
|
setCopied(true);
|
||||||
|
|
||||||
|
setTimeout(() => setCopied(false), 3000);
|
||||||
|
} catch (e) {
|
||||||
|
await message(e, { title: 'Cannot copy private key', type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-base font-semibold text-white/50">Public Key</span>
|
<span className="font-medium text-white">Private Key</span>
|
||||||
<input
|
|
||||||
readOnly
|
|
||||||
value={npub}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-base font-semibold text-white/50">Private Key</span>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
readOnly
|
readOnly
|
||||||
type={privkeyInput}
|
value={nsec.substring(0, 5) + '**************************************'}
|
||||||
value={nsec}
|
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"
|
||||||
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => showPrivateKey()}
|
onClick={() => copyPrivkey()}
|
||||||
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 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"
|
||||||
>
|
>
|
||||||
{privkeyInput === 'password' ? (
|
<CopyIcon className="h-4 w-4 text-white/70 group-hover:text-white" />
|
||||||
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
|
{copied ? 'Copied' : 'Copy'}
|
||||||
) : (
|
|
||||||
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm text-white/50">
|
</div>
|
||||||
<p>
|
<div className="flex flex-col gap-1">
|
||||||
Your private key is your password. If you lose this key, you will lose
|
<span className="font-medium text-white">Public Key</span>
|
||||||
access to your account! Copy it and keep it in a safe place. There is no way
|
<input
|
||||||
to reset your private key.
|
readOnly
|
||||||
</p>
|
value={npub}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
</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()}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating...' : 'Continue'}
|
||||||
|
</button>
|
||||||
|
<span className="text-center text-sm text-white/50">
|
||||||
|
By clicking 'Continue', you are ensuring that your keys are saved in
|
||||||
|
a safe place. You cannot recover these keys if they are lost.
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<Button preset="large-alt" onClick={() => download()}>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
@ -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 ? (
|
||||||
<>
|
<>
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
export function HardResetScreen() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>hard reset</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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">
|
||||||
|
Insert your nostr private key, in nsec or hex format
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
{...register('privkey', { required: true, minLength: 32 })}
|
{...register('privkey', { required: true, minLength: 32 })}
|
||||||
type={'password'}
|
type={passwordInput}
|
||||||
placeholder="nsec or hexstring"
|
placeholder="nsec1..."
|
||||||
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 border-t border-white/10 bg-white/20 px-3 py-1 text-white backdrop-blur-xl placeholder:text-white/70 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-red-400">
|
<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" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
@ -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" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
@ -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">
|
||||||
<User pubkey={db.account.pubkey} />
|
<div className="rounded-lg border-t border-white/10 bg-white/20 px-3 py-3">
|
||||||
|
<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 'Continue', 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>
|
||||||
|
@ -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(
|
data?.profiles.map((item: { pubkey: string; profile: { content: string } }) => (
|
||||||
(item: { pubkey: string; profile: { content: string } }) => (
|
|
||||||
<button
|
<button
|
||||||
key={item.pubkey}
|
key={item.pubkey}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleFollow(item.pubkey)}
|
onClick={() => toggleFollow(item.pubkey)}
|
||||||
className="inline-flex transform items-center justify-between px-4 py-2 hover:bg-white/20"
|
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"
|
||||||
>
|
>
|
||||||
<User pubkey={item.pubkey} fallback={item.profile?.content} />
|
<User pubkey={item.pubkey} fallback={item.profile?.content} />
|
||||||
{follows.includes(item.pubkey) && (
|
{follows.includes(item.pubkey) && (
|
||||||
<div>
|
<div className="absolute right-2 top-2">
|
||||||
<CheckCircleIcon className="h-4 w-4 text-green-400" />
|
<CheckCircleIcon className="h-4 w-4 text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
))
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mx-auto mt-4 w-full max-w-md">
|
||||||
<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>
|
||||||
|
{!loading ? (
|
||||||
<Link
|
<Link
|
||||||
to="/auth/onboarding/step-2"
|
to="/auth/onboarding/step-2"
|
||||||
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"
|
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
|
Skip, you can add later
|
||||||
</Link>
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-center text-sm text-white/50">
|
||||||
|
By clicking 'Continue', 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>
|
||||||
|
@ -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()}
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
Skip, you can add later
|
Skip, you can add later
|
||||||
</Link>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
</div>
|
||||||
</Frame>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
@ -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" />
|
||||||
|
@ -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
|
||||||
|
@ -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,12 +97,11 @@ 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="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||||
<div className="flex flex-col gap-6 px-3">
|
<div className="flex flex-col gap-6 px-3">
|
||||||
{DefaultWidgets.map((row: WidgetGroup) => renderItem(row))}
|
{DefaultWidgets.map((row: WidgetGroup) => renderItem(row))}
|
||||||
</div>
|
|
||||||
<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"
|
||||||
@ -116,5 +118,6 @@ export function WidgetList({ params }: { params: Widget }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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]
|
||||||
|
@ -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}`}
|
||||||
|
@ -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 () => {
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
@ -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);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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';
|
||||||
|
68
src/shared/widgets/other/learnNostr.tsx
Normal file
68
src/shared/widgets/other/learnNostr.tsx
Normal 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
88
src/stores/resources.ts
Normal 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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
@ -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>()(
|
||||||
|
@ -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;
|
||||||
|
@ -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])]);
|
||||||
|
|
||||||
|
@ -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
11
src/utils/types.d.ts
vendored
@ -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>;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user