chore: monorepo

This commit is contained in:
2023-12-25 14:28:39 +07:00
parent a6da07cd3f
commit 227c2ddefa
374 changed files with 19966 additions and 12758 deletions

View File

@@ -0,0 +1,50 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { message } from "@tauri-apps/plugin-dialog";
import { Dispatch, SetStateAction, useState } from "react";
export function AvatarUploader({
setPicture,
}: {
setPicture: Dispatch<SetStateAction<string>>;
}) {
const ark = useArk();
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {
try {
// start loading
setLoading(true);
const image = await ark.upload({});
if (image) {
setPicture(image);
setLoading(false);
}
return;
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, {
title: "Lume",
type: "error",
});
}
};
return (
<button
type="button"
onClick={() => uploadAvatar()}
className="inline-flex items-center justify-center rounded-lg border border-blue-200 bg-blue-100 px-2 py-1.5 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
"Change avatar"
)}
</button>
);
}

View File

@@ -0,0 +1,313 @@
import { useArk, useStorage } from "@lume/ark";
import { ArrowLeftIcon, InfoIcon, LoaderIcon } from "@lume/icons";
import { User } from "@lume/ui";
import { NDKKind, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { downloadDir } from "@tauri-apps/api/path";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { motion } from "framer-motion";
import { minidenticon } from "minidenticons";
import { generatePrivateKey, getPublicKey, nip19 } from "nostr-tools";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { AvatarUploader } from "./components/avatarUploader";
export function CreateAccountScreen() {
const [picture, setPicture] = useState("");
const [downloaded, setDownloaded] = useState(false);
const [loading, setLoading] = useState(false);
const [keys, setKeys] = useState<null | {
npub: string;
nsec: string;
}>(null);
const {
register,
handleSubmit,
formState: { isDirty, isValid },
} = useForm();
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon("lume new account", 90, 50),
)}`;
const onSubmit = async (data: { name: string; about: string }) => {
try {
setLoading(true);
const profile = {
...data,
name: data.name,
display_name: data.name,
bio: data.about,
picture: picture,
avatar: picture,
};
const userPrivkey = generatePrivateKey();
const userPubkey = getPublicKey(userPrivkey);
const userNpub = nip19.npubEncode(userPubkey);
const userNsec = nip19.nsecEncode(userPrivkey);
const signer = new NDKPrivateKeySigner(userPrivkey);
ark.updateNostrSigner({ signer });
const publish = await ark.createEvent({
content: JSON.stringify(profile),
kind: NDKKind.Metadata,
tags: [],
});
if (publish) {
await storage.createAccount({
id: userNpub,
pubkey: userPubkey,
privkey: userPrivkey,
});
setKeys({ npub: userNpub, nsec: userNsec });
setLoading(false);
} else {
toast.error("Cannot publish user profile, please try again later.");
setLoading(false);
}
} catch (e) {
return toast.error(e);
}
};
const copyNsec = async () => {
await writeText(keys.nsec);
};
const download = async () => {
try {
const downloadPath = await downloadDir();
const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
const filePath = await save({
defaultPath: `${downloadPath}/${fileName}`,
});
if (filePath) {
await writeTextFile(
filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${keys.npub}\nPrivate key: ${keys.nsec}`,
);
setDownloaded(true);
} // else { user cancel action }
} catch (e) {
return toast.error(e);
}
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="absolute left-[8px] top-2">
{!keys ? (
<button
type="button"
onClick={() => navigate(-1)}
className="group inline-flex items-center gap-2 text-sm font-medium"
>
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 group-hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-200 dark:group-hover:bg-neutral-700">
<ArrowLeftIcon className="h-4 w-4" />
</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">
Let&apos;s set up your account.
</h1>
<div className="flex flex-col gap-3">
{!keys ? (
<div className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<form
onSubmit={handleSubmit(onSubmit)}
className="mb-0 flex flex-col"
>
<input
type={"hidden"}
{...register("picture")}
value={picture}
/>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="font-semibold">Avatar</span>
<div className="flex h-36 w-full flex-col items-center justify-center gap-3 rounded-lg bg-neutral-100 dark:bg-neutral-900">
{picture.length > 0 ? (
<img
src={picture}
alt="user's avatar"
className="h-14 w-14 rounded-xl object-cover"
/>
) : (
<img
src={svgURI}
alt="user's avatar"
className="h-14 w-14 rounded-xl bg-black dark:bg-white"
/>
)}
<AvatarUploader setPicture={setPicture} />
</div>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="name" className="font-semibold">
Name *
</label>
<input
type={"text"}
{...register("name", {
required: true,
minLength: 1,
})}
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="about" className="font-semibold">
Bio
</label>
<textarea
{...register("about")}
spellCheck={false}
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-lg bg-blue-100 p-3 text-sm text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<InfoIcon className="h-8 w-8" />
<p>
There are many more settings you can configure from the
&quot;Settings&quot; screen. Be sure to visit it later.
</p>
</div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
"Create and Continue"
)}
</button>
</div>
</div>
</form>
</div>
) : (
<>
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{
opacity: 1,
y: 0,
}}
className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
>
<User pubkey={keys.npub} variant="simple" />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 80 }}
animate={{
opacity: 1,
y: 0,
}}
className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
>
<div className="flex flex-col gap-1.5">
<h5 className="font-semibold">Backup account</h5>
<div>
<p className="mb-2 select-text text-sm text-neutral-800 dark:text-neutral-200">
Your private key is your password. If you lose this key,
you will lose access to your account! Copy it and keep it
in a safe place.{" "}
<span className="text-red-500">
There is no way to reset your private key.
</span>
</p>
<p className="select-text text-sm text-neutral-800 dark:text-neutral-200">
Public key is used for sharing with other people so that
they can find you using the public key.
</p>
</div>
<div className="mt-3 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="nsec" className="text-sm font-semibold">
Private key
</label>
<div className="relative w-full">
<input
readOnly
value={`${keys.nsec.substring(
0,
10,
)}**************************`}
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2">
<button
type="button"
onClick={copyNsec}
className="rounded-md bg-neutral-200 px-2 py-1 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
>
Copy
</button>
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="nsec" className="text-sm font-semibold">
Public key
</label>
<input
readOnly
value={keys.npub}
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
</div>
{!downloaded ? (
<button
type="button"
onClick={() => download()}
className="mt-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Download account keys
</button>
) : null}
</div>
</motion.div>
</>
)}
{downloaded ? (
<motion.button
initial={{ opacity: 0, y: 50 }}
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")}
>
Finish
</motion.button>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Link } from 'react-router-dom';
export function FinishScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" />
<h1 className="text-2xl font-light">
Yo, you&apos;re ready to use <span className="font-bold">Lume</span>
</h1>
</div>
<div className="flex flex-col gap-2">
<Link
to="/auth/tutorials/note"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Start tutorial
</Link>
<Link
to="/"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Skip
</Link>
<p className="text-center text-sm font-medium text-neutral-500 dark:text-neutral-600">
You need to restart app to make changes in previous step take effect or you
can continue with Lume default settings
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,277 @@
import { useArk } from "@lume/ark";
import {
ArrowLeftIcon,
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 FollowScreen() {
const ark = useArk();
const navigate = useNavigate();
const { status, data } = useQuery({
queryKey: ["trending-profiles-widget"],
queryFn: async () => {
const res = await fetch("https://api.nostr.band/v0/trending/profiles");
if (!res.ok) {
throw new Error("Error");
}
return res.json();
},
});
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState<string[]>([]);
// 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="text-center">
<h1 className="text-2xl font-semibold">Dive into the nostrverse</h1>
<h2 className="text-neutral-700 dark:text-neutral-300">
Try following some users that interest you
<br />
to build up your timeline.
</h2>
</div>
<Accordion.Root type="single" defaultValue="recommended" collapsible>
<Accordion.Item
value="recommended"
className="mb-3 overflow-hidden rounded-xl"
>
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
Popular users
<ChevronDownIcon className="h-4 w-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
{POPULAR_USERS.map((pubkey) => (
<div
key={pubkey}
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
>
<div className="p-3">
<User pubkey={pubkey} variant="large" />
</div>
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(pubkey)}
className={twMerge(
"inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white",
follows.includes(pubkey)
? "bg-red-500 hover:bg-red-600"
: "bg-blue-500 hover:bg-blue-600",
)}
>
{follows.includes(pubkey) ? (
<>
<CancelIcon className="h-4 w-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Follow
</>
)}
</button>
</div>
</div>
))}
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item
value="trending"
className="mb-3 overflow-hidden rounded-xl"
>
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
Trending users
<ChevronDownIcon className="h-4 w-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
{status === "pending" ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
data?.profiles.map(
(item: {
pubkey: string;
profile: { content: string };
}) => (
<div
key={item.pubkey}
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
>
<div className="p-3">
<User pubkey={item.pubkey} variant="large" />
</div>
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(item.pubkey)}
className={twMerge(
"inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white",
follows.includes(item.pubkey)
? "bg-red-500 hover:bg-red-600"
: "bg-blue-500 hover:bg-blue-600",
)}
>
{follows.includes(item.pubkey) ? (
<>
<CancelIcon className="h-4 w-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Follow
</>
)}
</button>
</div>
</div>
),
)
)}
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item
value="lume"
className="mb-3 overflow-hidden rounded-xl"
>
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
Lume team
<ChevronDownIcon className="h-4 w-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
{LUME_USERS.map((pubkey) => (
<div
key={pubkey}
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
>
<div className="p-3">
<User pubkey={pubkey} variant="large" />
</div>
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(pubkey)}
className={twMerge(
"inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white",
follows.includes(pubkey)
? "bg-red-500 hover:bg-red-600"
: "bg-blue-500 hover:bg-blue-600",
)}
>
{follows.includes(pubkey) ? (
<>
<CancelIcon className="h-4 w-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Follow
</>
)}
</button>
</div>
</div>
))}
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</div>
<div className="absolute bottom-3 right-3 flex w-full items-center justify-end gap-2">
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex h-11 w-max items-center justify-center gap-2 rounded-lg bg-neutral-100 px-3 font-semibold hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-blue-800"
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</button>
<button
type="button"
onClick={submit}
disabled={loading}
className="inline-flex h-11 w-max items-center justify-center gap-2 rounded-lg bg-blue-500 px-3 font-semibold text-white hover:bg-blue-600"
>
Continue
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<ArrowRightIcon className="h-4 w-4" />
)}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,330 @@
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,148 @@
import { useStorage } from "@lume/ark";
import { InfoIcon } from "@lume/icons";
import * as Switch from "@radix-ui/react-switch";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
export function OnboardingScreen() {
const storage = useStorage();
const navigate = useNavigate();
const [settings, setSettings] = useState({
autoupdate: false,
outbox: false,
notification: false,
});
const next = () => {
if (!storage.account.contacts.length) return navigate("/auth/follow");
return navigate("/auth/finish");
};
const toggleOutbox = async () => {
await storage.createSetting("outbox", String(+!settings.outbox));
// update state
setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
};
const toggleAutoupdate = async () => {
await storage.createSetting("autoupdate", String(+!settings.autoupdate));
// update state
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
};
const toggleNofitication = async () => {
await requestPermission();
// update state
setSettings((prev) => ({ ...prev, notification: !settings.notification }));
};
useEffect(() => {
async function loadSettings() {
const permissionGranted = await isPermissionGranted();
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
const data = await storage.getAllSettings();
if (!data) return;
for (const item of data) {
if (item.key === "autoupdate")
setSettings((prev) => ({
...prev,
autoupdate: !!parseInt(item.value),
}));
if (item.key === "outbox")
setSettings((prev) => ({
...prev,
outbox: !!parseInt(item.value),
}));
}
}
loadSettings();
}, []);
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<h1 className="text-2xl font-light text-neutral-900 dark:text-neutral-100">
You&apos;re almost ready to use Lume.
</h1>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
Let&apos;s start personalizing your experience.
</h2>
</div>
<div className="flex flex-col gap-3">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<Switch.Root
checked={settings.autoupdate}
onClick={() => toggleAutoupdate()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold">Auto check for update on Login</h3>
<p className="text-sm">
Keep Lume up to date with latest version, always have new
features and bug free.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<Switch.Root
checked={settings.notification}
onClick={() => toggleNofitication()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold">Push notification</h3>
<p className="text-sm">
Enabling push notifications will allow you to receive
notifications from Lume directly on your device.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<Switch.Root
checked={settings.outbox}
onClick={() => toggleOutbox()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold">Use Gossip model (recommended)</h3>
<p className="text-sm">
Automatically discover relays to connect based on the
preferences of each author.
</p>
</div>
</div>
<div className="flex items-center gap-2 rounded-lg bg-blue-100 p-3 text-sm text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<InfoIcon className="h-8 w-8" />
<p>
There are many more settings you can configure from the
&quot;Settings&quot; screen. Be sure to visit it later.
</p>
</div>
<button
type="button"
onClick={next}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Continue
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { Link } from "react-router-dom";
export function TutorialFinishScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<img
src="/icon.png"
alt="Lume's logo"
className="mx-auto mb-1 h-auto w-16"
/>
<h1 className="text-2xl font-light">
Yo, you&apos;ve understood basic features 🎉
</h1>
</div>
<div className="flex flex-col gap-2">
<Link
to="/"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Start using Lume
</Link>
<Link
to="https://nostr.how/"
target="_blank"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Learn more about Nostr
</Link>
<p className="text-center text-sm font-medium text-neutral-500 dark:text-neutral-600">
If you&apos;ve trouble when user Lume, you can report the issue{" "}
<a
href="github.com/luminous-devs/lume"
target="_blank"
className="text-blue-500 !underline"
rel="noreferrer"
>
here
</a>
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { TextNote } from "@lume/ark";
import {
EditIcon,
ReactionIcon,
ReplyIcon,
RepostIcon,
ZapIcon,
} from "@lume/icons";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { Link } from "react-router-dom";
export function TutorialNoteScreen() {
const exampleEvent = new NDKEvent(undefined, {
id: "a3527670dd9b178bf7c2a9ea673b63bc8bfe774942b196691145343623c45821",
pubkey: "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9",
created_at: 1701355223,
kind: 1,
tags: [],
content: "good morning nostr, stay humble and stack sats 🫡",
sig: "9e0bd67ec25598744f20bff0fe360fdf190c4240edb9eea260e50f77e07f94ea767ececcc6270819b7f64e5e7ca1fe20b4971f46dc120e6db43114557f3a6dae",
});
return (
<div className="flex h-full w-full select-text items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="flex flex-col items-center gap-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<EditIcon className="h-5 w-5" />
</div>
<h1 className="text-2xl font-light">
What is a <span className="font-bold">Note?</span>
</h1>
</div>
<div className="flex flex-col gap-2">
<p className="px-3">
Posts on Nostr based Social Network client are usually called
&apos;Notes.&apos; Notes are arranged chronologically on the
timeline and are updated in real-time.
</p>
<p className="px-3 font-semibold">Here is one example:</p>
<TextNote event={exampleEvent} />
<p className="px-3 font-semibold">
Here are how you can interact with a note:
</p>
<div className="flex flex-col gap-2 px-3">
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<ReplyIcon className="h-5 w-5 text-blue-500" />
</div>
<p>
Reply - Click on this button to reply to a note. It&apos;s also
possible to reply to replies, continuing the conversation like a
thread.
</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<ReactionIcon className="h-5 w-5 text-red-500" />
</div>
<p>
Reaction - You can add reactions to the Note to express your
concern.
</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<RepostIcon className="h-5 w-5 text-teal-500" />
</div>
<p>
Repost - You can share that note to your own timeline. You can
also quote them with your comments.
</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<ZapIcon className="h-5 w-5 text-orange-500" />
</div>
<p>
Zap - You can send tip in Bitcoin to that note owner with
zero-fees
</p>
</div>
</div>
<div className="mt-5 flex gap-2 px-3">
<Link
to="/auth/finish"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Back
</Link>
<Link
to="/auth/tutorials/widget"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Continue
</Link>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,67 @@
import { BellIcon, HomeIcon, PlusIcon } from "@lume/icons";
import { Link } from "react-router-dom";
export function TutorialWidgetScreen() {
return (
<div className="flex h-full w-full select-text items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="flex flex-col items-center gap-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<HomeIcon className="h-5 w-5" />
</div>
<h1 className="text-2xl font-light">
The concept of <span className="font-bold">Widgets</span>
</h1>
</div>
<div className="flex flex-col gap-2 px-3">
<p>
Lume provides multiple widgets based on usage. You always can
control what you need to show on your Home.
</p>
<p className="font-semibold">Default widgets:</p>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<HomeIcon className="h-5 w-5" />
</div>
<p>Newsfeed - You can view notes from accounts you follow.</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<BellIcon className="h-5 w-5" />
</div>
<p>
Notification - You can view all notifications related to your
account.
</p>
</div>
<p>
If you want to add more widget, you can click to this button on Home
Screen.
</p>
<div className="flex h-24 w-full items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<button
type="button"
className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
<div className="mt-5 flex gap-2">
<Link
to="/auth/tutorials/note"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Back
</Link>
<Link
to="/auth/tutorials/finish"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Continue
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Link } from 'react-router-dom';
export function WelcomeScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-xs flex-col gap-10">
<div className="text-center">
<img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" />
<h1 className="text-2xl">
Welcome to <span className="font-bold">Lume</span>
</h1>
</div>
<div className="flex flex-col gap-2 px-8">
<Link
to="/auth/create"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Create new account
</Link>
<Link
to="/auth/import"
className="inline-flex h-11 w-full items-center justify-center rounded-lg font-medium text-neutral-900 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-900"
>
Log in
</Link>
</div>
</div>
</div>
);
}