feat: update login flow

This commit is contained in:
reya 2024-01-10 09:22:13 +07:00
parent 73f90ebaf9
commit f2504071cd
17 changed files with 619 additions and 705 deletions

View File

@ -189,12 +189,35 @@ export default function Router() {
},
},
{
path: "import",
path: "login",
async lazy() {
const { ImportAccountScreen } = await import(
"./routes/auth/import"
const { LoginScreen } = await import("./routes/auth/login");
return { Component: LoginScreen };
},
},
{
path: "login-key",
async lazy() {
const { LoginWithKey } = await import("./routes/auth/login-key");
return { Component: LoginWithKey };
},
},
{
path: "login-nsecbunker",
async lazy() {
const { LoginWithNsecbunker } = await import(
"./routes/auth/login-nsecbunker"
);
return { Component: ImportAccountScreen };
return { Component: LoginWithNsecbunker };
},
},
{
path: "login-oauth",
async lazy() {
const { LoginWithOAuth } = await import(
"./routes/auth/login-oauth"
);
return { Component: LoginWithOAuth };
},
},
{

View File

@ -89,74 +89,86 @@ export function CreateAccountScreen() {
};
const onSubmit = async (data: { username: string; email: string }) => {
setIsLoading(true);
try {
setIsLoading(true);
const domain = getDomainName(serviceId);
const service = services.find((ev) => ev.id === serviceId);
const domain = getDomainName(serviceId);
const service = services.find((ev) => ev.id === serviceId);
const localSigner = NDKPrivateKeySigner.generate();
const localUser = await localSigner.user();
const bunker = new NDK({
explicitRelayUrls: [
"wss://relay.nsecbunker.com/",
"wss://nostr.vulpem.com/",
],
});
await bunker.connect(2000);
const remoteSigner = new NDKNip46Signer(
bunker,
service.pubkey,
localSigner,
);
let authWindow: Window;
remoteSigner.addListener("authUrl", (authUrl: string) => {
authWindow = new Window(`auth-${serviceId}`, {
url: authUrl,
title: domain,
titleBarStyle: "overlay",
width: 415,
height: 600,
center: true,
closable: false,
// generate ndk for nsecbunker
const localSigner = NDKPrivateKeySigner.generate();
const bunker = new NDK({
explicitRelayUrls: [
"wss://relay.nsecbunker.com/",
"wss://nostr.vulpem.com/",
],
});
});
await bunker.connect(2000);
const account = await remoteSigner.createAccount(
data.username,
domain,
data.email,
);
// generate tmp remote singer for create account
const remoteSigner = new NDKNip46Signer(
bunker,
service.pubkey,
localSigner,
);
if (!account) {
authWindow.close();
// handle auth url request
let authWindow: Window;
remoteSigner.addListener("authUrl", (authUrl: string) => {
authWindow = new Window(`auth-${serviceId}`, {
url: authUrl,
title: domain,
titleBarStyle: "overlay",
width: 415,
height: 600,
center: true,
closable: false,
});
});
// create new account
const account = await remoteSigner.createAccount(
data.username,
domain,
data.email,
);
if (!account) {
authWindow.close();
setIsLoading(false);
return toast.error("Failed to create new account, try again later");
}
// add account to storage
await storage.createSetting("nsecbunker", "1");
await storage.createAccount({
pubkey: account,
privkey: localSigner.privateKey,
});
// get final signer with newly created account
const finalSigner = new NDKNip46Signer(bunker, account, localSigner);
await finalSigner.blockUntilReady();
// update main ndk instance signer
ark.updateNostrSigner({ signer: finalSigner });
console.log(ark.ndk.signer);
// remove default nsecbunker profile and contact list
await ark.createEvent({ kind: NDKKind.Metadata, content: "", tags: [] });
await ark.createEvent({ kind: NDKKind.Contacts, content: "", tags: [] });
setOnboarding(true);
setIsLoading(false);
return toast.error("Failed to create new account, try again later");
authWindow.close();
return navigate("/auth/onboarding");
} catch (e) {
setIsLoading(false);
toast.error(String(e));
}
await storage.createSetting("nsecbunker", "1");
await storage.createAccount({
pubkey: account,
privkey: localSigner.privateKey,
});
ark.updateNostrSigner({ signer: remoteSigner });
// remove default nsecbunker profile and contact list
await ark.createEvent({ kind: NDKKind.Metadata, content: "", tags: [] });
await ark.createEvent({ kind: NDKKind.Contacts, content: "", tags: [] });
setOnboarding(true);
setIsLoading(false);
authWindow.close();
return navigate("/auth/onboarding");
};
return (

View File

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

View File

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

View File

@ -0,0 +1,115 @@
import { useStorage } from "@lume/ark";
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from "@lume/icons";
import { getPublicKey, nip19 } from "nostr-tools";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export function LoginWithKey() {
const storage = useStorage();
const navigate = useNavigate();
const [showKey, setShowKey] = useState(false);
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
setError,
formState: { errors, isValid },
} = useForm();
const onSubmit = async (data: { nsec: string }) => {
try {
if (!data.nsec.startsWith("nsec1"))
return toast.error("You need to enter a private key start with nsec1");
setLoading(true);
const privkey = nip19.decode(data.nsec).data as string;
const pubkey = getPublicKey(privkey);
await storage.createAccount({
pubkey: pubkey,
privkey: privkey,
});
return navigate("/auth/onboarding");
} catch (e) {
setLoading(false);
setError("nsec", {
type: "manual",
message: String(e),
});
}
};
return (
<div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">Enter your Private Key</h1>
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
Lume will put your private key to{" "}
<span className="text-teal-500">
{storage.platform === "macos"
? "Apple Keychain"
: storage.platform === "windows"
? "Credential Manager"
: "Secret Service"}
</span>
.
<br />
It will be secured by your OS.
</p>
</div>
<div className="flex flex-col gap-6">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4 mb-0"
>
<div className="relative flex flex-col gap-1">
<input
type={showKey ? "text" : "password"}
{...register("nsec", { required: false })}
spellCheck={false}
autoCapitalize="none"
autoCorrect="none"
placeholder="nsec1..."
className="pl-3 pr-11 text-xl border-transparent rounded-xl h-14 bg-neutral-950 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
/>
{errors.nsec && (
<p className="text-sm text-center text-red-600">
{errors.nsec.message as string}
</p>
)}
<button
type="button"
onClick={() => setShowKey((state) => !state)}
className="absolute right-2 top-2 size-10 inline-flex items-center justify-center rounded-lg text-white bg-neutral-900 hover:bg-neutral-800"
>
{showKey ? (
<EyeOnIcon className="size-5" />
) : (
<EyeOffIcon className="size-5" />
)}
</button>
</div>
<button
type="submit"
disabled={!isValid}
className="inline-flex items-center justify-center w-full text-lg h-12 font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
"Continue"
)}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,110 @@
import { useArk, useStorage } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export function LoginWithNsecbunker() {
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
setError,
formState: { errors, isValid },
} = useForm();
const onSubmit = async (data: { npub: string }) => {
try {
if (!data.npub.startsWith("npub1"))
return toast.info("You need to enter a token start with npub1");
if (!data.npub.includes("#"))
return toast.info("Token must include #secret");
setLoading(true);
const bunker = new NDK({
explicitRelayUrls: [
"wss://relay.nsecbunker.com",
"wss://nostr.vulpem.com",
],
});
await bunker.connect(2000);
const pubkey = nip19.decode(data.npub.split("#")[0]).data as string;
const localSigner = NDKPrivateKeySigner.generate();
const remoteSigner = new NDKNip46Signer(bunker, data.npub, localSigner);
await remoteSigner.blockUntilReady();
ark.updateNostrSigner({ signer: remoteSigner });
await storage.createSetting("nsecbunker", "1");
await storage.createAccount({
pubkey,
privkey: localSigner.privateKey,
});
return navigate("/auth/onboarding");
} catch (e) {
setLoading(false);
setError("npub", {
type: "manual",
message: String(e),
});
}
};
return (
<div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">
Enter your nsecbunker token
</h1>
</div>
<div className="flex flex-col gap-6">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4 mb-0"
>
<div className="relative flex flex-col gap-1">
<input
type="text"
{...register("npub", { required: false })}
spellCheck={false}
autoCapitalize="none"
autoCorrect="none"
placeholder="npub1...#..."
className="px-3 text-xl border-transparent rounded-xl h-14 bg-neutral-950 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
/>
{errors.npub && (
<p className="text-sm text-center text-red-600">
{errors.npub.message as string}
</p>
)}
</div>
<button
type="submit"
disabled={!isValid}
className="inline-flex items-center justify-center w-full text-lg h-12 font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
"Continue"
)}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,139 @@
import { useArk, useStorage } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { NIP05 } from "@lume/types";
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { Window } from "@tauri-apps/api/window";
import { fetch } from "@tauri-apps/plugin-http";
import { nip19 } from "nostr-tools";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/;
export function LoginWithOAuth() {
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
setError,
formState: { errors, isValid },
} = useForm();
const onSubmit = async (data: { nip05: string }) => {
try {
setLoading(true);
const localPath = data.nip05.split("@")[0];
const service = data.nip05.split("@")[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
const req = await fetch(verifyURL, {
method: "GET",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
});
if (!req.ok)
return toast.error(
"Cannot verify your NIP-05 address, please try again later.",
);
const res: NIP05 = await req.json();
if (!res.names[localPath.toLowerCase()] || !res.names[localPath])
return toast.error(
"Cannot verify your NIP-05 address, please try again later.",
);
const pubkey =
(res.names[localPath] as string) ||
(res.names[localPath.toLowerCase()] as string);
if (!res.nip46[pubkey])
return toast.error("Cannot found NIP-46 with this address");
const nip46Relays = res.nip46[pubkey] as unknown as string[];
const bunker = new NDK({
explicitRelayUrls: nip46Relays || [
"wss://relay.nsecbunker.com",
"wss://nostr.vulpem.com",
],
});
await bunker.connect(2000);
const localSigner = NDKPrivateKeySigner.generate();
const remoteSigner = new NDKNip46Signer(bunker, pubkey, localSigner);
await remoteSigner.blockUntilReady();
ark.updateNostrSigner({ signer: remoteSigner });
await storage.createSetting("nsecbunker", "1");
await storage.createAccount({
pubkey,
privkey: localSigner.privateKey,
});
return navigate("/auth/onboarding");
} catch (e) {
setLoading(false);
setError("npub", {
type: "manual",
message: String(e),
});
}
};
return (
<div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">Enter your NIP-05 address</h1>
</div>
<div className="flex flex-col gap-6">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4 mb-0"
>
<div className="relative flex flex-col gap-1">
<input
type="email"
{...register("nip05", { required: false })}
spellCheck={false}
autoCapitalize="none"
autoCorrect="none"
placeholder="satoshi@nostr.me"
className="px-3 text-xl border-transparent rounded-xl h-14 bg-neutral-950 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
/>
{errors.nip05 && (
<p className="text-sm text-center text-red-600">
{errors.nip05.message as string}
</p>
)}
</div>
<button
type="submit"
disabled={!isValid}
className="inline-flex items-center justify-center w-full text-lg h-12 font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
"Continue"
)}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
import { Link } from "react-router-dom";
export function LoginScreen() {
return (
<div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">
Continue your experience on Nostr
</h1>
</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Link
to="/auth/login-oauth"
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600"
>
OAuth Login
</Link>
<Link
to="/auth/login-nsecbunker"
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
>
Login with nsecbunker
</Link>
</div>
<div className="flex flex-col gap-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-neutral-900" />
</div>
<div className="relative flex justify-center">
<span className="px-2 font-medium bg-black text-neutral-600">
Or (Not recommend)
</span>
</div>
</div>
<div>
<Link
to="/auth/login-key"
className="mb-2 inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
>
Login with Private Key
</Link>
<p className="text-sm text-center text-neutral-500">
Lume will store your Private Key in{" "}
<span className="text-teal-600">OS Secure Storage</span>
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,9 @@
import { useStorage } from "@lume/ark";
import { useArk, useStorage } from "@lume/ark";
import { InfoIcon, LoaderIcon } from "@lume/icons";
import { delay } from "@lume/utils";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKKind } from "@nostr-dev-kit/ndk";
import * as Switch from "@radix-ui/react-switch";
import { useQueryClient } from "@tanstack/react-query";
import {
isPermissionGranted,
requestPermission,
@ -10,7 +12,9 @@ import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
export function OnboardingScreen() {
const ark = useArk();
const storage = useStorage();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
@ -33,7 +37,35 @@ export function OnboardingScreen() {
const completeAuth = async () => {
setLoading(true);
await delay(1200);
// get account contacts
await ark.getUserContacts(storage.account.pubkey);
// refetch newsfeed
await queryClient.prefetchInfiniteQuery({
queryKey: ["timeline-9999"],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: storage.account.contacts,
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
return events;
},
});
navigate("/");
};

View File

@ -41,7 +41,7 @@ export function WelcomeScreen() {
)}
</button>
<Link
to="/auth/import"
to="/auth/login"
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
>
Login

View File

@ -9,7 +9,7 @@ export function ContactCard() {
const { status, data } = useQuery({
queryKey: ["contacts"],
queryFn: async () => {
const contacts = await ark.getUserContacts({});
const contacts = await ark.getUserContacts();
return contacts;
},
refetchOnWindowFocus: false,

View File

@ -8,7 +8,7 @@ export function EditContactScreen() {
const { status, data } = useQuery({
queryKey: ["contacts"],
queryFn: async () => {
return await ark.getUserContacts({});
return await ark.getUserContacts();
},
refetchOnWindowFocus: false,
});

View File

@ -108,10 +108,11 @@ export class Ark {
}
}
public async getUserProfile({ pubkey }: { pubkey: string }) {
public async getUserProfile(pubkey?: string) {
try {
// get clean pubkey without any special characters
let hexstring = pubkey.replace(/[^a-zA-Z0-9]/g, "").replace("nostr:", "");
const currentUserPubkey = this.#storage.account.pubkey;
if (
hexstring.startsWith("npub1") ||
@ -125,7 +126,9 @@ export class Ark {
if (decoded.type === "naddr") hexstring = decoded.data.pubkey;
}
const user = this.ndk.getUser({ pubkey: hexstring });
const user = this.ndk.getUser({
pubkey: pubkey ? hexstring : currentUserPubkey,
});
const profile = await user.fetchProfile({
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
@ -138,23 +141,33 @@ export class Ark {
}
}
public async getUserContacts({
pubkey = undefined,
outbox = undefined,
}: {
pubkey?: string;
outbox?: boolean;
}) {
public async getUserContacts(pubkey?: string) {
try {
const user = this.ndk.getUser({
pubkey: pubkey ? pubkey : this.#storage.account.pubkey,
});
const contacts = [...(await user.follows(undefined, outbox))].map(
(user) => user.pubkey,
);
// get clean pubkey without any special characters
let hexstring = pubkey.replace(/[^a-zA-Z0-9]/g, "").replace("nostr:", "");
const currentUserPubkey = this.#storage.account.pubkey;
if (pubkey === this.#storage.account.pubkey)
if (
hexstring.startsWith("npub1") ||
hexstring.startsWith("nprofile1") ||
hexstring.startsWith("naddr1")
) {
const decoded = nip19.decode(hexstring);
if (decoded.type === "nprofile") hexstring = decoded.data.pubkey;
if (decoded.type === "npub") hexstring = decoded.data;
if (decoded.type === "naddr") hexstring = decoded.data.pubkey;
}
const user = this.ndk.getUser({
pubkey: pubkey ? hexstring : currentUserPubkey,
});
const contacts = [...(await user.follows())].map((user) => user.pubkey);
if (!pubkey || pubkey === this.#storage.account.pubkey)
this.#storage.account.contacts = contacts;
return contacts;
} catch (e) {
throw new Error(e);
@ -507,7 +520,10 @@ export class Ark {
signal,
});
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
if (!res.ok) {
console.log(res);
throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
}
const data: NIP05 = await res.json();

View File

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

View File

@ -366,6 +366,7 @@ export class LumeStorage {
const currentSetting = await this.checkSettingValue(key);
if (!currentSetting) {
this.settings[key] === !!parseInt(value);
return await this.#db.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2);",
[key, value],

View File

@ -159,4 +159,9 @@ export interface NIP05 {
names: {
[key: string]: string;
};
nip46: {
[key: string]: {
[key: string]: string[];
};
};
}

View File

@ -32,9 +32,7 @@ export function OnboardingProfileSettingsScreen() {
navigate("/follow");
}
const oldProfile = await ark.getUserProfile({
pubkey: storage.account.pubkey,
});
const oldProfile = await ark.getUserProfile();
const profile: NDKUserProfile = {
...data,
@ -55,9 +53,6 @@ export function OnboardingProfileSettingsScreen() {
if (publish) {
setLoading(false);
navigate("/follow");
} else {
toast.error("Cannot publish your profile, please try again later.");
setLoading(false);
}
} catch (e) {
setLoading(false);