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

13
apps/desktop/index.html Normal file
View File

@@ -0,0 +1,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lume</title>
</head>
<body
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-neutral-950 antialiased dark:text-neutral-50"
>
<div id="root"></div>
<script type="module" src="./src/main.jsx"></script>
</body>
</html>

101
apps/desktop/package.json Normal file
View File

@@ -0,0 +1,101 @@
{
"name": "lume",
"description": "the communication app",
"private": true,
"version": "3.0.0",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@columns/notification": "workspace:^",
"@columns/timeline": "workspace:^",
"@getalby/sdk": "^3.2.1",
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^",
"@lume/ui": "workspace:^",
"@lume/utils": "workspace:^",
"@nostr-dev-kit/ndk": "^2.3.1",
"@nostr-fetch/adapter-ndk": "^0.14.1",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-toolbar": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.14.2",
"@tauri-apps/api": "2.0.0-alpha.11",
"@tauri-apps/plugin-autostart": "2.0.0-alpha.3",
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.3",
"@tauri-apps/plugin-dialog": "2.0.0-alpha.3",
"@tauri-apps/plugin-fs": "2.0.0-alpha.3",
"@tauri-apps/plugin-http": "2.0.0-alpha.3",
"@tauri-apps/plugin-notification": "2.0.0-alpha.3",
"@tauri-apps/plugin-os": "2.0.0-alpha.4",
"@tauri-apps/plugin-process": "2.0.0-alpha.3",
"@tauri-apps/plugin-shell": "2.0.0-alpha.3",
"@tauri-apps/plugin-sql": "2.0.0-alpha.3",
"@tauri-apps/plugin-updater": "2.0.0-alpha.3",
"@tauri-apps/plugin-upload": "2.0.0-alpha.3",
"@tiptap/extension-character-count": "^2.1.13",
"@tiptap/extension-document": "^2.1.13",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/extension-paragraph": "^2.1.13",
"@tiptap/extension-placeholder": "^2.1.13",
"@tiptap/extension-text": "^2.1.13",
"@tiptap/pm": "^2.1.13",
"@tiptap/react": "^2.1.13",
"@tiptap/starter-kit": "^2.1.13",
"@tiptap/suggestion": "^2.1.13",
"@vidstack/react": "^1.9.8",
"clsx": "^2.0.0",
"dayjs": "^1.11.10",
"framer-motion": "^10.16.16",
"html-to-text": "^9.0.5",
"light-bolt11-decoder": "^3.0.0",
"lru-cache": "^10.1.0",
"markdown-to-jsx": "^7.3.2",
"minidenticons": "^4.2.0",
"nanoid": "^5.0.4",
"nostr-fetch": "^0.14.1",
"nostr-tools": "^1.17.0",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-currency-input-field": "^3.6.12",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.2",
"react-hotkeys-hook": "^4.4.1",
"react-router-dom": "^6.21.0",
"react-string-replace": "^1.1.1",
"smol-toml": "^1.1.3",
"sonner": "^1.2.4",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.8",
"virtua": "^0.18.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@types/html-to-text": "^9.0.4",
"@types/node": "^20.10.5",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.16",
"cross-env": "^7.0.3",
"encoding": "^0.1.13",
"postcss": "^8.4.32",
"tailwind-merge": "^1.14.0",
"typescript": "^5.3.3",
"vite": "^4.5.1",
"vite-tsconfig-paths": "^4.2.2"
}
}

6138
apps/desktop/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

52
apps/desktop/src/app.css Normal file
View File

@@ -0,0 +1,52 @@
/* Vidstack */
@import '@vidstack/react/player/styles/default/theme.css';
@import '@vidstack/react/player/styles/default/layouts/video.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.break-p {
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
}
.prose :where(iframe):not(:where([class~='not-prose'] *)) {
@apply mx-auto aspect-video h-auto w-full;
}
}
html {
font-size: 14px;
}
a {
@apply cursor-default no-underline !important;
}
button {
@apply cursor-default focus:outline-none;
}
input::-ms-reveal,
input::-ms-clear {
display: none;
}
::-webkit-input-placeholder {
line-height: normal;
}
.border {
background-clip: padding-box;
}
.ProseMirror p.is-empty::before {
@apply pointer-events-none float-left h-0 text-neutral-600 content-[attr(data-placeholder)] dark:text-neutral-400;
}
.ProseMirror img.ProseMirror-selectednode {
@apply outline-blue-500;
}

314
apps/desktop/src/app.tsx Normal file
View File

@@ -0,0 +1,314 @@
import { useStorage } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import {
AppLayout,
AuthLayout,
ComposerLayout,
HomeLayout,
SettingsLayout,
} from "@lume/ui";
import { fetch } from "@tauri-apps/plugin-http";
import {
RouterProvider,
createBrowserRouter,
defer,
redirect,
} from "react-router-dom";
import { ErrorScreen } from "./routes/error";
export default function App() {
const storage = useStorage();
const router = createBrowserRouter([
{
element: <AppLayout platform={storage.platform} />,
children: [
{
path: "/",
element: <HomeLayout />,
errorElement: <ErrorScreen />,
loader: async () => {
if (!storage.account) return redirect("auth/welcome");
return null;
},
children: [
{
index: true,
async lazy() {
const { HomeScreen } = await import("./routes/home");
return { Component: HomeScreen };
},
},
{
path: "nwc",
async lazy() {
const { NWCScreen } = await import("./routes/nwc");
return { Component: NWCScreen };
},
},
{
path: "relays",
async lazy() {
const { RelaysScreen } = await import("./routes/relays");
return { Component: RelaysScreen };
},
},
{
path: "relays/:url",
loader: async ({ params }) => {
return defer({
relay: fetch(`https://${params.url}`, {
method: "GET",
headers: {
Accept: "application/nostr+json",
},
}).then((res) => res.json()),
});
},
async lazy() {
const { RelayScreen } = await import("./routes/relays/relay");
return { Component: RelayScreen };
},
},
{
path: "new",
element: <ComposerLayout />,
children: [
{
index: true,
async lazy() {
const { NewPostScreen } = await import("./routes/new/post");
return { Component: NewPostScreen };
},
},
{
path: "article",
async lazy() {
const { NewArticleScreen } = await import(
"./routes/new/article"
);
return { Component: NewArticleScreen };
},
},
{
path: "file",
async lazy() {
const { NewFileScreen } = await import("./routes/new/file");
return { Component: NewFileScreen };
},
},
{
path: "privkey",
async lazy() {
const { NewPrivkeyScreen } = await import(
"./routes/new/privkey"
);
return { Component: NewPrivkeyScreen };
},
},
],
},
{
path: "settings",
element: <SettingsLayout />,
children: [
{
index: true,
async lazy() {
const { UserSettingScreen } = await import(
"./routes/settings"
);
return { Component: UserSettingScreen };
},
},
{
path: "edit-profile",
async lazy() {
const { EditProfileScreen } = await import(
"./routes/settings/editProfile"
);
return { Component: EditProfileScreen };
},
},
{
path: "edit-contact",
async lazy() {
const { EditContactScreen } = await import(
"./routes/settings/editContact"
);
return { Component: EditContactScreen };
},
},
{
path: "general",
async lazy() {
const { GeneralSettingScreen } = await import(
"./routes/settings/general"
);
return { Component: GeneralSettingScreen };
},
},
{
path: "backup",
async lazy() {
const { BackupSettingScreen } = await import(
"./routes/settings/backup"
);
return { Component: BackupSettingScreen };
},
},
{
path: "advanced",
async lazy() {
const { AdvancedSettingScreen } = await import(
"./routes/settings/advanced"
);
return { Component: AdvancedSettingScreen };
},
},
{
path: "about",
async lazy() {
const { AboutScreen } = await import(
"./routes/settings/about"
);
return { Component: AboutScreen };
},
},
],
},
],
},
{
path: "depot",
children: [
{
index: true,
loader: () => {
const depot = storage.checkDepot();
if (!depot) return redirect("/depot/onboarding/");
return null;
},
async lazy() {
const { DepotScreen } = await import("./routes/depot");
return { Component: DepotScreen };
},
},
{
path: "onboarding",
async lazy() {
const { DepotOnboardingScreen } = await import(
"./routes/depot/onboarding"
);
return { Component: DepotOnboardingScreen };
},
},
],
},
],
},
{
path: "auth",
element: <AuthLayout platform={storage.platform} />,
errorElement: <ErrorScreen />,
children: [
{
path: "welcome",
async lazy() {
const { WelcomeScreen } = await import("./routes/auth/welcome");
return { Component: WelcomeScreen };
},
},
{
path: "create",
async lazy() {
const { CreateAccountScreen } = await import(
"./routes/auth/create"
);
return { Component: CreateAccountScreen };
},
},
{
path: "import",
async lazy() {
const { ImportAccountScreen } = await import(
"./routes/auth/import"
);
return { Component: ImportAccountScreen };
},
},
{
path: "onboarding",
async lazy() {
const { OnboardingScreen } = await import(
"./routes/auth/onboarding"
);
return { Component: OnboardingScreen };
},
},
{
path: "follow",
async lazy() {
const { FollowScreen } = await import("./routes/auth/follow");
return { Component: FollowScreen };
},
},
{
path: "finish",
async lazy() {
const { FinishScreen } = await import("./routes/auth/finish");
return { Component: FinishScreen };
},
},
{
path: "tutorials/note",
async lazy() {
const { TutorialNoteScreen } = await import(
"./routes/auth/tutorials/note"
);
return { Component: TutorialNoteScreen };
},
},
{
path: "tutorials/widget",
async lazy() {
const { TutorialWidgetScreen } = await import(
"./routes/auth/tutorials/widget"
);
return { Component: TutorialWidgetScreen };
},
},
{
path: "tutorials/posting",
async lazy() {
const { TutorialPostingScreen } = await import(
"./routes/auth/tutorials/posting"
);
return { Component: TutorialPostingScreen };
},
},
{
path: "tutorials/finish",
async lazy() {
const { TutorialFinishScreen } = await import(
"./routes/auth/tutorials/finish"
);
return { Component: TutorialFinishScreen };
},
},
],
},
]);
return (
<RouterProvider
router={router}
fallbackElement={
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-6 w-6 animate-spin" />
</div>
}
future={{ v7_startTransition: true }}
/>
);
}

26
apps/desktop/src/main.jsx Normal file
View File

@@ -0,0 +1,26 @@
import { LumeProvider } from '@lume/ark';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRoot } from 'react-dom/client';
import { Toaster } from 'sonner';
import App from './app';
import './app.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // 10 seconds
},
},
});
const container = document.getElementById('root');
const root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<Toaster position="top-center" theme="system" closeButton />
<LumeProvider>
<App />
</LumeProvider>
</QueryClientProvider>
);

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>
);
}

View File

@@ -0,0 +1,71 @@
import { useArk, useStorage } from "@lume/ark";
import { LoaderIcon, RunIcon } from "@lume/icons";
import { User } from "@lume/ui";
import { NDKKind } from "@nostr-dev-kit/ndk";
import { useState } from "react";
import { toast } from "sonner";
export function DepotContactCard() {
const ark = useArk();
const storage = useStorage();
const [status, setStatus] = useState(false);
const backupContact = async () => {
try {
setStatus(true);
const event = await ark.getEventByFilter({
filter: {
authors: [storage.account.pubkey],
kinds: [NDKKind.Contacts],
},
});
// broadcast to depot
const publish = await event.publish();
if (publish) {
setStatus(false);
toast.success("Backup contact list successfully.");
}
} catch (e) {
setStatus(false);
toast.error(e);
}
};
return (
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
<div className="isolate flex -space-x-2">
{storage.account.contacts?.slice(0, 8).map((item) => (
<User key={item} pubkey={item} variant="ministacked" />
))}
{storage.account.contacts?.length > 8 ? (
<div className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-300 text-neutral-900 ring-1 ring-white dark:bg-neutral-700 dark:text-neutral-100 dark:ring-black">
<span className="text-[8px] font-medium">
+{storage.account.contacts?.length - 8}
</span>
</div>
) : null}
</div>
</div>
<div className="inline-flex shrink-0 items-center justify-between">
<div className="text-sm font-medium">Contacts</div>
<button
type="button"
onClick={backupContact}
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
>
{status ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<RunIcon className="size-4" />
)}
Backup
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { CancelIcon, PlusIcon, UserAddIcon, UserRemoveIcon } from "@lume/icons";
import { User } from "@lume/ui";
import * as Dialog from "@radix-ui/react-dialog";
import { resolveResource } from "@tauri-apps/api/path";
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
import { nip19 } from "nostr-tools";
import { useEffect, useState } from "react";
import { parse, stringify } from "smol-toml";
import { toast } from "sonner";
export function DepotMembers() {
const [members, setMembers] = useState<Set<string>>(null);
const [tmpMembers, setTmpMembers] = useState<Array<string>>([]);
const [newMember, setNewMember] = useState("");
const addMember = async () => {
if (!newMember.startsWith("npub1"))
return toast.error("You need to enter a valid npub");
try {
const pubkey = nip19.decode(newMember).data as string;
setTmpMembers((prev) => [...prev, pubkey]);
} catch (e) {
console.error(e);
}
};
const removeMember = (member: string) => {
setTmpMembers((prev) => prev.filter((item) => item !== member));
};
const updateMembers = async () => {
setMembers(new Set(tmpMembers));
const defaultConfig = await resolveResource("resources/config.toml");
const config = await readTextFile(defaultConfig);
const configContent = parse(config);
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
configContent.authorization["pubkey_whitelist"] = [...members];
const newConfig = stringify(configContent);
return await writeTextFile(defaultConfig, newConfig);
};
useEffect(() => {
async function loadConfig() {
const defaultConfig = await resolveResource("resources/config.toml");
const config = await readTextFile(defaultConfig);
const configContent = parse(config);
setTmpMembers(
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
Array.from(configContent.authorization["pubkey_whitelist"]),
);
}
loadConfig();
}, []);
return (
<Dialog.Root>
<div className="flex items-center justify-between rounded-lg bg-neutral-50 p-5 dark:bg-neutral-950">
<div className="flex flex-col items-start">
<h3 className="text-lg font-semibold">Members</h3>
<p className="text-neutral-700 dark:text-neutral-300">
Only allowed users can publish event to your Depot
</p>
</div>
<div className="inline-flex items-center gap-2">
<div className="isolate flex -space-x-2">
{tmpMembers.slice(0, 5).map((item) => (
<User key={item} pubkey={item} variant="stacked" />
))}
{tmpMembers.length > 5 ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-700">
<span className="text-xs font-medium">
+{tmpMembers.length}
</span>
</div>
) : null}
</div>
<Dialog.Trigger className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-lg bg-blue-500 px-3 text-white hover:bg-blue-600">
<UserAddIcon className="size-4" />
Manage
</Dialog.Trigger>
</div>
</div>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-xl overflow-hidden rounded-xl bg-white dark:bg-black">
<div className="inline-flex h-14 w-full shrink-0 items-center justify-between border-b border-neutral-100 px-5 dark:border-neutral-900">
<Dialog.Title className="text-center font-semibold">
Manage member
</Dialog.Title>
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={updateMembers}
className="inline-flex h-8 w-max items-center justify-center rounded-lg bg-blue-500 px-2.5 text-sm font-medium text-white hover:bg-blue-600"
>
Update
</button>
<Dialog.Close className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800">
<CancelIcon className="size-4" />
</Dialog.Close>
</div>
</div>
<div className="pb-3">
<div className="relative mb-2 mt-4 w-full px-5">
<input
type="text"
spellCheck={false}
value={newMember}
onChange={(e) => setNewMember(e.target.value)}
placeholder="npub1..."
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 pl-3 pr-20 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"
/>
<button
type="button"
onClick={addMember}
className="absolute right-7 top-1/2 inline-flex h-7 w-max -translate-y-1/2 transform items-center justify-center gap-1 rounded-md bg-neutral-200 px-2.5 text-sm font-medium text-blue-500 hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
>
<PlusIcon className="size-4" />
Add
</button>
</div>
{tmpMembers.map((member) => (
<div
key={member}
className="group flex items-center justify-between px-5 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<User pubkey={member} variant="simple" />
<button
type="button"
onClick={() => removeMember(member)}
className="hidden size-6 items-center justify-center rounded-md bg-neutral-200 group-hover:inline-flex hover:bg-red-200 dark:bg-neutral-800 dark:hover:bg-red-800 dark:hover:text-red-200"
>
<UserRemoveIcon className="size-4 text-red-500" />
</button>
</div>
))}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,60 @@
import { useArk, useStorage } from "@lume/ark";
import { LoaderIcon, RunIcon } from "@lume/icons";
import { User } from "@lume/ui";
import { NDKKind } from "@nostr-dev-kit/ndk";
import { useState } from "react";
import { toast } from "sonner";
export function DepotProfileCard() {
const ark = useArk();
const storage = useStorage();
const [status, setStatus] = useState(false);
const backupProfile = async () => {
try {
setStatus(true);
const event = await ark.getEventByFilter({
filter: {
authors: [storage.account.pubkey],
kinds: [NDKKind.Metadata],
},
});
// broadcast to depot
const publish = await event.publish();
if (publish) {
setStatus(false);
toast.success("Backup profile successfully.");
}
} catch (e) {
setStatus(false);
toast.error(JSON.stringify(e));
}
};
return (
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
<User pubkey={storage.account.pubkey} variant="simple" />
</div>
<div className="inline-flex shrink-0 items-center justify-between">
<div className="text-sm font-medium">Profile</div>
<button
type="button"
onClick={backupProfile}
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
>
{status ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<RunIcon className="size-4" />
)}
Backup
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useArk, useStorage } from "@lume/ark";
import { LoaderIcon, RunIcon } from "@lume/icons";
import { NDKKind } from "@nostr-dev-kit/ndk";
import { useEffect, useState } from "react";
import { toast } from "sonner";
export function DepotRelaysCard() {
const ark = useArk();
const storage = useStorage();
const [status, setStatus] = useState(false);
const [relaySize, setRelaySize] = useState(0);
const backupRelays = async () => {
try {
setStatus(true);
const event = await ark.getEventByFilter({
filter: {
authors: [storage.account.pubkey],
kinds: [NDKKind.RelayList],
},
});
// broadcast to depot
const publish = await event.publish();
if (publish) {
setStatus(false);
toast.success("Backup profile successfully.");
}
} catch (e) {
setStatus(false);
toast.error(JSON.stringify(e));
}
};
useEffect(() => {
async function loadRelays() {
const event = await ark.getEventByFilter({
filter: {
authors: [storage.account.pubkey],
kinds: [NDKKind.RelayList],
},
});
if (event) setRelaySize(event.tags.length);
}
loadRelays();
}, []);
return (
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
<p className="text-lg font-semibold">{relaySize} relays</p>
</div>
<div className="inline-flex shrink-0 items-center justify-between">
<div className="text-sm font-medium">Relay List</div>
<button
type="button"
onClick={backupRelays}
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
>
{status ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<RunIcon className="size-4" />
)}
Backup
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,221 @@
import { useArk, useStorage } from "@lume/ark";
import { ChevronDownIcon, DepotIcon, GossipIcon } from "@lume/icons";
import { NDKKind } from "@nostr-dev-kit/ndk";
import * as Collapsible from "@radix-ui/react-collapsible";
import { appConfigDir } from "@tauri-apps/api/path";
import { invoke } from "@tauri-apps/api/primitives";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DepotContactCard } from "./components/contact";
import { DepotMembers } from "./components/members";
import { DepotProfileCard } from "./components/profile";
import { DepotRelaysCard } from "./components/relays";
export function DepotScreen() {
const ark = useArk();
const storage = useStorage();
const [dataPath, setDataPath] = useState("");
const [tunnelUrl, setTunnelUrl] = useState("");
const openFolder = async () => {
await invoke("show_in_folder", {
path: `${dataPath}/nostr.db`,
});
};
const updateRelayList = async () => {
try {
if (tunnelUrl.length < 1)
return toast.info("Please enter a valid relay url");
if (!tunnelUrl.startsWith("ws"))
return toast.info("Please enter a valid relay url");
const relayUrl = new URL(tunnelUrl.replace(/\s/g, ""));
if (!/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/.test(relayUrl.host)) return;
const relayEvent = await ark.getEventByFilter({
filter: {
authors: [storage.account.pubkey],
kinds: [NDKKind.RelayList],
},
});
let publish: { id: string; seens: string[] };
if (!relayEvent) {
publish = await ark.createEvent({
kind: NDKKind.RelayList,
tags: [["r", tunnelUrl, ""]],
});
}
const newTags = relayEvent.tags ?? [];
newTags.push(["r", tunnelUrl, ""]);
publish = await ark.createEvent({
kind: NDKKind.RelayList,
tags: newTags,
});
if (publish) {
await storage.createSetting("tunnel_url", tunnelUrl);
toast.success("Update relay list successfully.");
setTunnelUrl("");
}
} catch (e) {
console.error(e);
toast.error("Error");
}
};
useEffect(() => {
async function loadConfig() {
const appDir = await appConfigDir();
setDataPath(appDir);
}
loadConfig();
}, []);
return (
<div className="flex h-full w-full rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
<div className="h-full w-72 shrink-0 rounded-l-xl bg-white/50 px-8 pt-8 backdrop-blur-xl dark:bg-black/50">
<div className="flex flex-col justify-center gap-4">
<div className="size-16 rounded-xl bg-gradient-to-bl from-teal-300 to-teal-600 p-1">
<div className="relative inline-flex h-full w-full items-center justify-center overflow-hidden rounded-lg bg-gradient-to-bl from-teal-400 to-teal-700 shadow-sm shadow-white/20">
<DepotIcon className="size-8 text-white" />
</div>
</div>
<h1 className="text-xl font-semibold">Depot is running</h1>
</div>
<div className="mt-8 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium">Relay URL</div>
<div className="inline-flex h-10 w-full select-text items-center rounded-lg bg-black/10 px-3 text-sm backdrop-blur-xl dark:bg-white/10">
ws://localhost:6090
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium">Database</div>
<div className="inline-flex h-10 w-full items-center gap-2 truncate rounded-lg bg-black/10 p-1 backdrop-blur-xl dark:bg-white/10">
<p className="shrink-0 pl-2 text-sm">nostr.db (SQLite)</p>
<button
type="button"
onClick={openFolder}
className="inline-flex h-full w-full items-center justify-center rounded-md bg-white text-sm font-medium shadow hover:bg-blue-500 hover:text-white dark:bg-black"
>
Open
</button>
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto rounded-r-xl bg-white pb-20 dark:bg-black">
<div className="mb-5 flex h-12 items-center border-b border-neutral-100 px-5 dark:border-neutral-900">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Actions
</h3>
</div>
<div className="flex flex-col gap-5 px-5">
<Collapsible.Root
defaultOpen
className="flex flex-col overflow-hidden rounded-xl border border-transparent bg-neutral-50 data-[state=open]:border-blue-500 dark:bg-neutral-950"
>
<Collapsible.Trigger className="flex h-20 items-center justify-between px-5 hover:bg-neutral-100 dark:hover:bg-neutral-900">
<div className="flex flex-col items-start">
<h3 className="text-lg font-semibold">Expose</h3>
<p className="text-neutral-700 dark:text-neutral-300">
Make your Depot visible in the Internet, everyone can connect
into it.
</p>
</div>
<ChevronDownIcon className="size-5 shrink-0" />
</Collapsible.Trigger>
<Collapsible.Content>
<div className="flex w-full flex-col gap-4 p-5">
<div>
<p className="mb-1 font-medium">ngrok</p>
<input
readOnly
value="ngrok http --domain=<your_domain> 6090"
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>
<p className="mb-1 font-medium">Cloudflare Tunnel</p>
<input
readOnly
value="cloudflared tunnel --url localhost:6090"
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>
<p className="mb-1 font-medium">Local Tunnel</p>
<input
readOnly
value="lt --port 6090"
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 className="mt-4 border-t border-neutral-100 pt-4 dark:border-neutral-900">
<div className="inline-flex items-center gap-2">
<GossipIcon className="size-5 text-blue-500" />
<h3 className="mb-1 font-semibold">
Support Gossip Model (Recommended)
</h3>
</div>
<div className="w-full max-w-xl">
<p className=" text-balance">
By adding to Relay List, other Nostr Client which support
Gossip Model will automatically connect to your Depot and
improve the discoverability.
</p>
<div className="mt-2 inline-flex w-full items-center gap-2">
<input
type="text"
value={tunnelUrl}
onChange={(e) => setTunnelUrl(e.target.value)}
spellCheck={false}
placeholder="wss://"
className="h-10 flex-1 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"
/>
<button
type="button"
onClick={updateRelayList}
className="inline-flex h-10 w-max shrink-0 items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
>
Update
</button>
</div>
</div>
</div>
</div>
</Collapsible.Content>
</Collapsible.Root>
<Collapsible.Root className="flex flex-col overflow-hidden rounded-xl border border-transparent bg-neutral-50 data-[state=open]:border-blue-500 dark:bg-neutral-950">
<Collapsible.Trigger className="flex h-20 items-center justify-between px-5 hover:bg-neutral-100 dark:hover:bg-neutral-900">
<div className="flex flex-col items-start">
<h3 className="text-lg font-semibold">Backup (Recommended)</h3>
<p className="text-neutral-700 dark:text-neutral-300">
Backup all your data to Depot, it always live on your machine.
</p>
</div>
<ChevronDownIcon className="size-5 shrink-0" />
</Collapsible.Trigger>
<Collapsible.Content>
<div className="grid grid-cols-3 gap-4 px-5 py-5">
<DepotProfileCard />
<DepotContactCard />
<DepotRelaysCard />
</div>
</Collapsible.Content>
</Collapsible.Root>
<DepotMembers />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { useArk, useStorage } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { delay } from "@lume/utils";
import { resolveResource } from "@tauri-apps/api/path";
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { parse, stringify } from "smol-toml";
import { toast } from "sonner";
export function DepotOnboardingScreen() {
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const launchDepot = async () => {
try {
setLoading(true);
// get default config
const defaultConfig = await resolveResource("resources/config.toml");
const config = await readTextFile(defaultConfig);
const parsedConfig = parse(config);
// add current user to whitelist
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
parsedConfig.authorization["pubkey_whitelist"].push(
storage.account.pubkey,
);
// update new config
const newConfig = stringify(parsedConfig);
await writeTextFile(defaultConfig, newConfig);
// launch depot
await storage.launchDepot();
await storage.createSetting("depot", "1");
await delay(2000); // delay 2s to make sure depot is running
// default depot url: ws://localhost:6090
// #TODO: user can custom depot url
const connect = await ark.connectDepot();
if (connect) {
toast.success("Your Depot is successfully launch.");
setLoading(false);
navigate("/depot/");
}
} catch (e) {
toast.error(e);
}
};
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-10 rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
<div className="flex flex-col items-center gap-8">
<div className="text-center">
<h1 className="mb-1 text-3xl font-semibold text-neutral-400 dark:text-neutral-600">
Run your Personal Nostr Relay inside Lume
</h1>
<h2 className="text-4xl font-semibold">Your Relay, Your Control.</h2>
</div>
<div className="rounded-xl bg-blue-100 p-1.5 dark:bg-blue-900">
<button
type="button"
onClick={launchDepot}
className="inline-flex h-11 w-36 transform items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white active:translate-y-1"
>
{loading ? (
<>
<LoaderIcon className="h-5 w-5 animate-spin" />
Launching...
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
>
<path
fillRule="evenodd"
d="M12 2.25a.75.75 0 0 1 .75.75v9a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM6.166 5.106a.75.75 0 0 1 0 1.06 8.25 8.25 0 1 0 11.668 0 .75.75 0 1 1 1.06-1.06c3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788a.75.75 0 0 1 1.06 0Z"
clipRule="evenodd"
/>
</svg>
Launch
</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import { useStorage } from "@lume/ark";
import { downloadDir } from "@tauri-apps/api/path";
import { message, save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { relaunch } from "@tauri-apps/plugin-process";
import { useRouteError } from "react-router-dom";
interface RouteError {
statusText: string;
message: string;
}
export function ErrorScreen() {
const storage = useStorage();
const error = useRouteError() as RouteError;
const restart = async () => {
await relaunch();
};
const download = async () => {
try {
const downloadPath = await downloadDir();
const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
const filePath = await save({
defaultPath: `${downloadPath}/${fileName}`,
});
const nsec = await storage.loadPrivkey(storage.account.pubkey);
if (filePath) {
if (nsec) {
await writeTextFile(
filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${storage.account.id}\nPrivate key: ${nsec}`,
);
} else {
await writeTextFile(
filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${storage.account.id}`,
);
}
} // else { user cancel action }
} catch (e) {
await message(e, {
title: "Cannot download account keys",
type: "error",
});
}
};
return (
<div
data-tauri-drag-region
className="relative flex h-screen w-screen items-center justify-center bg-blue-600"
>
<div className="flex w-full max-w-2xl flex-col items-start gap-8">
<div className="flex flex-col">
<h1 className="mb-3 text-4xl font-semibold text-blue-400">
Sorry, an unexpected error has occurred.
</h1>
<h3 className="text-3xl font-semibold leading-snug text-white">
Don&apos;t panic, your account is safe.
<br />
Here are what things you can do:
</h3>
</div>
<div className="flex w-full flex-col gap-3">
<div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
<div className="text-xl font-semibold text-white">
1. Try to close and re-open the app
</div>
<button
type="button"
onClick={() => restart()}
className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
>
Restart
</button>
</div>
<div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
<div className="text-xl font-semibold text-white">
2. Backup Nostr account
</div>
<button
type="button"
onClick={() => download()}
className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
>
Download
</button>
</div>
<div className="rounded-xl bg-blue-700 px-3 py-4">
<div className="flex w-full flex-col gap-2">
<div className="flex w-full items-center justify-between">
<div className="text-xl font-semibold text-white">
3. Report this issue to Lume&apos;s Devs
</div>
<a
href="https://github.com/luminous-devs/lume/issues/new"
target="_blank"
rel="noreferrer"
className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
>
Report
</a>
</div>
<div className="inline-flex h-16 items-center justify-center overflow-y-auto rounded-lg border border-dashed border-red-300 bg-blue-800 px-5">
<p className="select-text break-all text-red-400">
{error.statusText || error.message}
</p>
</div>
</div>
</div>
<div className="rounded-xl bg-blue-700 px-3 py-4">
<div className="flex w-full flex-col gap-1.5">
<div className="text-xl font-semibold text-white">
4. Use another Nostr client
</div>
<div className="select-text text-lg font-medium text-blue-300">
<p>
While waiting for Lume&apos;s Devs to release the bug fixes,
you always can use other Nostr clients with your account:
</p>
<div className="mt-2 flex flex-col gap-1 text-white">
<a
className="hover:!underline"
href="https://snort.social"
target="_blank"
rel="noreferrer"
>
snort.social
</a>
<a
className="hover:!underline"
href="https://primal.net"
target="_blank"
rel="noreferrer"
>
primal.net
</a>
<a
className="hover:!underline"
href="https://nostrudel.ninja"
target="_blank"
rel="noreferrer"
>
nostrudel.ninja
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from './newsfeed';
export * from './notification';

View File

@@ -0,0 +1,104 @@
import { RepostNote, TextNote, Widget, useArk, useStorage } from "@lume/ark";
import { ArrowRightCircleIcon, LoaderIcon, TimelineIcon } from "@lume/icons";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo, useRef } from "react";
import { VList, VListHandle } from "virtua";
export function NewsfeedWidget() {
const ark = useArk();
const storage = useStorage();
const ref = useRef<VListHandle>();
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["newsfeed"],
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.length
? [storage.account.pubkey]
: storage.account.contacts,
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
refetchOnWindowFocus: false,
});
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data],
);
const renderItem = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<Widget.Root>
<Widget.Header
id="9999"
queryKey={["newsfeed"]}
title="Timeline"
icon={<TimelineIcon className="h-5 w-5" />}
/>
<Widget.Content>
<VList ref={ref} overscan={2} className="flex-1">
{isLoading ? (
<div className="inline-flex h-16 items-center justify-center gap-2 px-3 py-1.5">
<LoaderIcon className="size-5" />
Loading
</div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 py-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-5 w-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@@ -0,0 +1,174 @@
import { NoteSkeleton, TextNote, Widget, useArk, useStorage } from "@lume/ark";
import {
AnnouncementIcon,
ArrowRightCircleIcon,
LoaderIcon,
} from "@lume/icons";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind, NDKSubscription } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { sendNativeNotification } from "apps/desktop/src/utils/notification";
import { useEffect, useMemo } from "react";
import { VList } from "virtua";
export function NotificationWidget() {
const ark = useArk();
const storage = useStorage();
const queryClient = useQueryClient();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["notification"],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [
NDKKind.Text,
NDKKind.Repost,
NDKKind.Reaction,
NDKKind.Zap,
],
"#p": [storage.account.pubkey],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data],
);
const renderEvent = (event: NDKEvent) => {
if (event.pubkey === storage.account.pubkey) return null;
return <TextNote key={event.id} event={event} />;
};
useEffect(() => {
let sub: NDKSubscription = undefined;
if (status === "success" && storage.account) {
const filter = {
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
"#p": [storage.account.pubkey],
since: Math.floor(Date.now() / 1000),
};
sub = ark.subscribe({
filter,
closeOnEose: false,
cb: async (event) => {
queryClient.setQueryData(
["notification"],
(prev: { pageParams: number; pages: Array<NDKEvent[]> }) => ({
...prev,
pages: [[event], ...prev.pages],
}),
);
const profile = await ark.getUserProfile({ pubkey: event.pubkey });
switch (event.kind) {
case NDKKind.Text:
return await sendNativeNotification(
`${
profile.displayName || profile.name
} has replied to your note`,
);
case NDKKind.Repost:
return await sendNativeNotification(
`${
profile.displayName || profile.name
} has reposted to your note`,
);
case NDKKind.Reaction:
return await sendNativeNotification(
`${profile.displayName || profile.name} has reacted ${
event.content
} to your note`,
);
case NDKKind.Zap:
return await sendNativeNotification(
`${
profile.displayName || profile.name
} has zapped to your note`,
);
default:
break;
}
},
});
}
return () => {
if (sub) sub.stop();
};
}, [status]);
return (
<Widget.Root>
<Widget.Header
id="9998"
queryKey={["notification"]}
title="Notification"
icon={<AnnouncementIcon className="h-5 w-5" />}
/>
<Widget.Content>
<VList className="flex-1" overscan={2}>
{status === "pending" ? (
<NoteSkeleton />
) : allEvents.length < 1 ? (
<div className="my-3 flex w-full items-center justify-center gap-2">
<div>🎉</div>
<p className="text-center font-medium text-neutral-900 dark:text-neutral-100">
Hmm! Nothing new yet.
</p>
</div>
) : (
allEvents.map((event) => renderEvent(event))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</Widget.Content>
</Widget.Root>
);
}

View File

@@ -0,0 +1,100 @@
import { NotificationColumn } from "@columns/notification";
import { TimelineColumn } from "@columns/timeline";
import { useStorage } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { WidgetProps } from "@lume/types";
import { WIDGET_KIND } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { VList, VListHandle } from "virtua";
export function HomeScreen() {
const storage = useStorage();
const ref = useRef<VListHandle>(null);
const { isLoading, data } = useQuery({
queryKey: ["widgets"],
queryFn: async () => {
const dbWidgets = await storage.getWidgets();
const defaultWidgets = [
{
id: "9999",
title: "Newsfeed",
content: "",
kind: WIDGET_KIND.newsfeed,
},
];
return [...defaultWidgets, ...dbWidgets];
},
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
});
const [selectedIndex, setSelectedIndex] = useState(-1);
const renderItem = (widget: WidgetProps) => {
switch (widget.kind) {
case WIDGET_KIND.notification:
return <NotificationColumn key={widget.id} />;
case WIDGET_KIND.newsfeed:
return <TimelineColumn key={widget.id} />;
default:
return <TimelineColumn key={widget.id} />;
}
};
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
);
}
return (
<div className="h-full w-full">
<VList
ref={ref}
className="h-full w-full flex-nowrap overflow-x-auto !overflow-y-hidden scrollbar-none focus:outline-none"
initialItemSize={420}
tabIndex={0}
horizontal
onKeyDown={(e) => {
if (!ref.current) return;
switch (e.code) {
case "ArrowUp":
case "ArrowLeft": {
e.preventDefault();
const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex);
ref.current.scrollToIndex(prevIndex, {
align: "center",
smooth: true,
});
break;
}
case "ArrowDown":
case "ArrowRight": {
e.preventDefault();
const nextIndex = Math.min(selectedIndex + 1, data.length - 1);
setSelectedIndex(nextIndex);
ref.current.scrollToIndex(nextIndex, {
align: "center",
smooth: true,
});
break;
}
default:
break;
}
}}
>
{data.map((widget) => renderItem(widget))}
<div className="h-full w-[200px]" />
</VList>
</div>
);
}

View File

@@ -0,0 +1,314 @@
import { useArk } from "@lume/ark";
import {
BoldIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ItalicIcon,
LoaderIcon,
ThreadsIcon,
} from "@lume/icons";
import { NDKKind, NDKTag } from "@nostr-dev-kit/ndk";
import CharacterCount from "@tiptap/extension-character-count";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, FloatingMenu, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { twMerge } from "tailwind-merge";
import { Markdown } from "tiptap-markdown";
import {
ArticleCoverUploader,
MediaUploader,
MentionPopup,
} from "./components";
export function NewArticleScreen() {
const ark = useArk();
const [height, setHeight] = useState(0);
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState("");
const [summary, setSummary] = useState({ open: false, content: "" });
const [cover, setCover] = useState("");
const navigate = useNavigate();
const containerRef = useRef(null);
const ident = useMemo(() => String(Date.now()), []);
const editor = useEditor({
extensions: [
StarterKit.configure(),
Placeholder.configure({ placeholder: "Type something..." }),
Image.configure({
HTMLAttributes: {
class:
"rounded-lg w-full object-cover h-auto max-h-[400px] border border-neutral-200 dark:border-neutral-800 outline outline-1 outline-offset-0 outline-neutral-300 dark:outline-neutral-700",
},
}),
CharacterCount.configure(),
Markdown.configure({
html: false,
tightLists: true,
linkify: true,
transformPastedText: true,
}),
],
content: JSON.parse(localStorage.getItem("editor-post") || "{}"),
editorProps: {
attributes: {
class:
"outline-none prose prose-lg prose-neutral max-w-none select-text whitespace-pre-line break-words dark:prose-invert hover:prose-a:text-blue-500",
},
},
onUpdate: ({ editor }) => {
const jsonContent = JSON.stringify(editor.getJSON());
localStorage.setItem("editor-article", jsonContent);
},
});
const submit = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
setLoading(true);
// get markdown content
const content = editor.storage.markdown.getMarkdown();
// define tags
const tags: NDKTag[] = [
["d", ident],
["title", title],
["image", cover],
["summary", summary.content],
["published_at", String(Math.floor(Date.now() / 1000))],
];
// add hashtag to tags if present
const hashtags = content
.split(/\s/gm)
.filter((s: string) => s.startsWith("#"));
if (hashtags) {
for (const tag of hashtags) {
tags.push(["t", tag.replace("#", "")]);
}
}
// publish
const publish = await ark.createEvent({
content,
tags,
kind: NDKKind.Article,
});
if (publish) {
toast.success(
`Broadcasted to ${publish.seens.length} relays successfully.`,
);
// update state
setLoading(false);
// reset editor
editor.commands.clearContent();
localStorage.setItem("editor-article", "{}");
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
useLayoutEffect(() => {
setHeight(containerRef.current.clientHeight);
}, []);
return (
<div className="flex flex-1 flex-col justify-between">
<div className="flex-1 overflow-y-auto">
<div
className="flex flex-col gap-4"
ref={containerRef}
style={{ height: `${height}px` }}
>
{cover ? (
<img
src={cover}
alt="post cover"
className="h-72 w-full rounded-lg object-cover"
/>
) : null}
<div className="group flex justify-between gap-2">
<input
name="title"
className="h-9 flex-1 border-none bg-transparent px-0 text-2xl font-semibold text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 focus:border-none focus:outline-none focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="Untitled"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<div
className={twMerge(
"inline-flex shrink-0 gap-2 group-hover:inline-flex",
title.length > 0 ? "" : "hidden",
)}
>
<ArticleCoverUploader setCover={setCover} />
<button
type="button"
onClick={() =>
setSummary((prev) => ({ ...prev, open: !prev.open }))
}
className="inline-flex h-9 w-max items-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
>
<ThreadsIcon className="h-4 w-4" />
Add summary
</button>
</div>
</div>
{summary.open ? (
<div className="flex gap-3">
<div className="h-16 w-1 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" />
<div className="flex-1">
<textarea
className="h-16 w-full border-none bg-transparent px-1 py-1 text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="A brief summary of your article"
value={summary.content}
onChange={(e) =>
setSummary((prev) => ({ ...prev, content: e.target.value }))
}
/>
</div>
</div>
) : null}
<div>
{editor && (
<FloatingMenu
editor={editor}
tippyOptions={{ duration: 100 }}
className="ml-36 inline-flex h-10 items-center gap-1 rounded-lg border border-neutral-200 bg-neutral-100 px-px dark:border-neutral-800 dark:bg-neutral-900"
>
<button
type="button"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("heading", { level: 1 })
? "bg-white shadow dark:bg-black"
: "",
)}
>
<Heading1Icon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("heading", { level: 2 })
? "bg-white shadow dark:bg-black"
: "",
)}
>
<Heading2Icon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("heading", { level: 3 })
? "bg-white shadow dark:bg-black"
: "",
)}
>
<Heading3Icon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("bold")
? "bg-white shadow dark:bg-black"
: "",
)}
>
<BoldIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("italic")
? "bg-white shadow dark:bg-black"
: "",
)}
>
<ItalicIcon className="h-5 w-5" />
</button>
</FloatingMenu>
)}
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
</div>
</div>
</div>
<div>
<div className="mb-3 flex h-12 w-full items-center rounded-lg bg-yellow-100 px-3 text-yellow-700">
<p className="text-sm">
Article editor is still in beta. If you need a stable and more
reliable feature, you can use <b>Habla (habla.news)</b> instead.
</p>
</div>
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
<div className="inline-flex items-center gap-3">
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.characters()} characters
</span>
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
-
</span>
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
<b>Identifier:</b>
{ident}
</span>
</div>
<div className="flex items-center">
<div className="inline-flex items-center gap-2">
<MediaUploader editor={editor} />
<MentionPopup editor={editor} />
</div>
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
<button
type="button"
onClick={() => submit()}
disabled={editor?.isEmpty}
className="inline-flex h-9 w-max items-center justify-center rounded-lg bg-blue-500 px-2.5 font-medium text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading === true ? (
<LoaderIcon className="h-5 w-5 animate-spin" />
) : (
"Publish article"
)}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { ImageIcon, LoaderIcon } from "@lume/icons";
import { message, open } from "@tauri-apps/plugin-dialog";
import { readBinaryFile } from "@tauri-apps/plugin-fs";
import { useState } from "react";
export function ArticleCoverUploader({ setCover }) {
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => {
try {
// start loading
setLoading(true);
const selected = await open({
multiple: false,
filters: [
{
name: "Media",
extensions: [
"png",
"jpeg",
"jpg",
"gif",
"mp4",
"mp3",
"webm",
"mkv",
"avi",
"mov",
],
},
],
});
if (!selected) {
setLoading(false);
return;
}
const file = await readBinaryFile(selected.path);
const blob = new Blob([file]);
const data = new FormData();
data.append("fileToUpload", blob);
data.append("submit", "Upload Image");
const res = await fetch("https://nostr.build/api/v2/upload/files", {
method: "POST",
body: data,
});
if (res.ok) {
const json = await res.json();
const content = json.data[0];
setCover(content.url);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, {
title: "Lume",
type: "error",
});
}
};
return (
<button
type="button"
onClick={uploadToNostrBuild}
className="inline-flex h-9 w-max items-center justify-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ImageIcon className="h-4 w-4" />
Add cover
</>
)}
</button>
);
}

View File

@@ -0,0 +1,5 @@
export * from './articleCoverUploader';
export * from './mediaUploader';
export * from './mentionPopup';
export * from './mentionPopupItem';
export * from './mentionList';

View File

@@ -0,0 +1,46 @@
import { useArk } from "@lume/ark";
import { MediaIcon } from "@lume/icons";
import { message } from "@tauri-apps/plugin-dialog";
import { Editor } from "@tiptap/react";
import { useState } from "react";
export function MediaUploader({ editor }: { editor: Editor }) {
const ark = useArk();
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => {
try {
// start loading
setLoading(true);
const image = await ark.upload({
fileExts: ["mp4", "mp3", "webm", "mkv", "avi", "mov"],
});
if (image) {
editor.commands.setImage({ src: image });
editor.commands.createParagraphNear();
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, {
title: "Lume",
type: "error",
});
}
};
return (
<button
type="button"
onClick={() => uploadToNostrBuild()}
className="inline-flex h-9 w-max items-center justify-center gap-1.5 rounded-lg bg-neutral-100 px-2 text-sm font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<MediaIcon className="h-5 w-5" />
{loading ? "Uploading..." : "Add media"}
</button>
);
}

View File

@@ -0,0 +1,115 @@
import { NDKCacheUserProfile } from "@lume/types";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import {
Ref,
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { twMerge } from "tailwind-merge";
type MentionListRef = {
onKeyDown: (props: { event: Event }) => boolean;
};
const List = (
props: {
items: NDKCacheUserProfile[];
command: (arg0: { id: string }) => void;
},
ref: Ref<unknown>,
) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const item = props.items[index];
if (item) {
props.command({ id: item.pubkey });
}
};
const upHandler = () => {
setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length,
);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="flex w-[200px] flex-col overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 shadow-lg shadow-neutral-500/20 dark:border-neutral-800 dark:bg-neutral-950 dark:shadow-neutral-300/50">
{props.items.length ? (
props.items.map((item, index) => (
<button
type="button"
key={item.pubkey}
onClick={() => selectItem(index)}
className={twMerge(
"inline-flex h-11 items-center gap-2 rounded-md px-2",
index === selectedIndex
? "bg-neutral-100 dark:bg-neutral-900"
: "",
)}
>
<Avatar.Root className="h-8 w-8 shrink-0">
<Avatar.Image
src={item.image}
alt={item.name}
loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md"
/>
<Avatar.Fallback delayMs={150}>
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(item.name, 90, 50),
)}`}
alt={item.name}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<h5 className="max-w-[150px] truncate text-sm font-medium">
{item.name}
</h5>
</button>
))
) : (
<div className="text-center text-sm font-medium">No result</div>
)}
</div>
);
};
export const MentionList = forwardRef<MentionListRef>(List);

View File

@@ -0,0 +1,51 @@
import { useStorage } from "@lume/ark";
import { MentionIcon } from "@lume/icons";
import * as Popover from "@radix-ui/react-popover";
import { Editor } from "@tiptap/react";
import { nip19 } from "nostr-tools";
import { MentionPopupItem } from "./mentionPopupItem";
export function MentionPopup({ editor }: { editor: Editor }) {
const storage = useStorage();
const insertMention = (pubkey: string) => {
editor.commands.insertContent(`nostr:${nip19.npubEncode(pubkey)}`);
};
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-max items-center justify-center gap-1.5 rounded-lg bg-neutral-100 px-2 text-sm font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<MentionIcon className="h-5 w-5" />
Mention
</button>
</Popover.Trigger>
<Popover.Content
side="top"
sideOffset={5}
className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900"
>
<div className="flex flex-col gap-1 py-1">
{storage.account.contacts.length ? (
storage.account.contacts.map((item) => (
<button
key={item}
type="button"
onClick={() => insertMention(item)}
>
<MentionPopupItem pubkey={item} />
</button>
))
) : (
<div className="flex h-16 items-center justify-center">
Contact list is empty
</div>
)}
</div>
</Popover.Content>
</Popover.Root>
);
}

View File

@@ -0,0 +1,57 @@
import { useProfile } from "@lume/ark";
import { displayNpub } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import { useMemo } from "react";
export function MentionPopupItem({ pubkey }: { pubkey: string }) {
const { isLoading, user } = useProfile(pubkey);
const svgURI = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(pubkey, 90, 50),
)}`,
[pubkey],
);
if (isLoading) {
return (
<div className="flex items-center gap-2.5 px-2">
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
<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-neutral-400 dark:bg-neutral-600" />
<span className="h-3 w-1/3 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
</div>
</div>
);
}
return (
<div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-neutral-200 dark:bg-neutral-800">
<Avatar.Root className="shirnk-0 h-8 w-8">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={pubkey}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-col items-start gap-px">
<h5 className="max-w-[10rem] truncate text-sm font-medium leading-none text-neutral-900 dark:text-neutral-100">
{user?.display_name || user?.displayName || user?.name}
</h5>
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
{displayNpub(pubkey, 16)}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,184 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { message, open } from "@tauri-apps/plugin-dialog";
import { readBinaryFile } from "@tauri-apps/plugin-fs";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export function NewFileScreen() {
const ark = useArk();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [isPublish, setIsPublish] = useState(false);
const [metadata, setMetadata] = useState<string[][] | null>(null);
const [caption, setCaption] = useState("");
const uploadFile = async () => {
try {
setLoading(true);
const selected = await open({
multiple: false,
filters: [
{
name: "Media",
extensions: [
"png",
"jpeg",
"jpg",
"gif",
"mp4",
"mp3",
"webm",
"mkv",
"avi",
"mov",
],
},
],
});
if (!selected) {
setLoading(false);
return;
}
const file = await readBinaryFile(selected.path);
const blob = new Blob([file]);
const data = new FormData();
data.append("fileToUpload", blob);
data.append("submit", "Upload Image");
const res = await fetch("https://nostr.build/api/v2/upload/files", {
method: "POST",
body: data,
});
if (res.ok) {
const json = await res.json();
const data = json.data[0];
setMetadata([
["url", data.url],
["m", data.mime ?? "application/octet-stream"],
["x", data.sha256 ?? ""],
["size", data.size.toString() ?? "0"],
["dim", `${data.dimensions.width}x${data.dimensions.height}` ?? "0"],
["blurhash", data.blurhash ?? ""],
["thumb", data.thumbnail ?? ""],
]);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, {
title: "Lume",
type: "error",
});
}
};
const submit = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
setIsPublish(true);
const publish = await ark.createEvent({
kind: 1063,
tags: metadata,
content: caption,
});
if (publish) {
toast.success(
`Broadcasted to ${publish.seens.length} relays successfully.`,
);
setMetadata(null);
setIsPublish(false);
}
} catch (e) {
setIsPublish(false);
toast.error(e);
}
};
return (
<div className="h-full">
<div className="flex h-96 gap-4 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<button
type="button"
onClick={uploadFile}
className="flex h-full flex-1 flex-col items-center justify-center rounded-lg border border-dashed border-neutral-200 bg-neutral-50 p-2 hover:border-blue-500 hover:text-blue-500 dark:border-neutral-800 dark:bg-neutral-950"
>
{loading ? (
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
) : !metadata ? (
<div className="flex flex-col text-center">
<h5 className="text-lg font-semibold">
Click or drag a file to this area to upload
</h5>
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
Supports: jpg, png, webp, gif, mov, mp4 or mp3
</p>
</div>
) : (
<div>
<img
src={metadata[0][1]}
alt={metadata[1][1]}
className="aspect-square h-full w-full rounded-lg object-cover shadow-lg"
/>
</div>
)}
</button>
{metadata ? (
<div className="flex h-full flex-1 flex-col justify-between">
<div className="flex flex-col gap-2 py-2">
{metadata.map((item, index) => (
<div key={item[0] + index} className="flex min-w-0 gap-2">
<h5 className="w-24 shrink-0 truncate font-semibold capitalize text-neutral-600 dark:text-neutral-400">
{item[0]}
</h5>
<p className="w-72 truncate">{item[1]}</p>
</div>
))}
</div>
<div className="flex flex-col gap-2">
<input
name="caption"
type="text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="Caption (Optional)..."
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-900 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={submit}
disabled={!metadata}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{isPublish ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
"Share"
)}
</button>
</div>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,173 @@
import { MentionNote, useArk, useSuggestion, useWidget } from "@lume/ark";
import { CancelIcon, LoaderIcon } from "@lume/icons";
import { WIDGET_KIND } from "@lume/utils";
import { NDKKind } from "@nostr-dev-kit/ndk";
import CharacterCount from "@tiptap/extension-character-count";
import Image from "@tiptap/extension-image";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { convert } from "html-to-text";
import { nip19 } from "nostr-tools";
import { useLayoutEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { MediaUploader, MentionPopup } from "./components";
export function NewPostScreen() {
const ark = useArk();
const { addWidget } = useWidget();
const { suggestion } = useSuggestion();
const [loading, setLoading] = useState(false);
const [height, setHeight] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const containerRef = useRef(null);
const editor = useEditor({
extensions: [
StarterKit.configure(),
Placeholder.configure({ placeholder: "Sharing some thoughts..." }),
Image.configure({
HTMLAttributes: {
class:
"rounded-lg w-full object-cover h-auto max-h-[400px] border border-neutral-200 dark:border-neutral-800 outline outline-1 outline-offset-0 outline-neutral-300 dark:outline-neutral-700",
},
}),
CharacterCount.configure(),
Mention.configure({
suggestion,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
renderLabel({ options, node }) {
const npub = nip19.npubEncode(node.attrs.id);
return `nostr:${npub}`;
},
}),
],
content: JSON.parse(localStorage.getItem("editor-post") || "{}"),
editorProps: {
attributes: {
class:
"outline-none prose prose-lg prose-neutral max-w-none select-text whitespace-pre-line break-words dark:prose-invert hover:prose-a:text-blue-500",
},
},
onUpdate: ({ editor }) => {
const jsonContent = JSON.stringify(editor.getJSON());
localStorage.setItem("editor-post", jsonContent);
},
});
const submit = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
setLoading(true);
// get plaintext content
const html = editor.getHTML();
const serializedContent = convert(html, {
selectors: [
{ selector: "a", options: { linkBrackets: false } },
{ selector: "img", options: { linkBrackets: false } },
],
});
// add reply to tags if present
const replyTo = searchParams.get("replyTo");
const rootReplyTo = searchParams.get("rootReplyTo");
// publish event
const publish = await ark.createEvent({
kind: NDKKind.Text,
tags: [],
content: serializedContent,
replyTo,
rootReplyTo,
});
if (publish) {
toast.success(
`Broadcasted to ${publish.seens.length} relays successfully.`,
);
// update state
setLoading(false);
setSearchParams({});
// open new widget with this event id
if (!replyTo) {
addWidget.mutate({
title: "Thread",
content: publish.id,
kind: WIDGET_KIND.thread,
});
}
// reset editor
editor.commands.clearContent();
localStorage.setItem("editor-post", "{}");
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
useLayoutEffect(() => {
setHeight(containerRef.current.clientHeight);
}, []);
return (
<div className="flex h-[500px] flex-1 flex-col gap-4">
<div className="flex-1 overflow-y-auto">
<div ref={containerRef} style={{ height: `${height}px` }}>
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
{searchParams.get("replyTo") && (
<div className="relative max-w-lg">
<MentionNote eventId={searchParams.get("replyTo")} />
<button
type="button"
onClick={() => setSearchParams({})}
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded bg-neutral-200 px-2 dark:bg-neutral-800"
>
<CancelIcon className="h-5 w-5" />
</button>
</div>
)}
</div>
</div>
<div className="inline-flex h-16 w-full items-center justify-between border-t border-neutral-100 bg-neutral-50 dark:border-neutral-900 dark:bg-neutral-950">
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.characters()} characters
</span>
<div className="flex items-center">
<div className="inline-flex items-center gap-2">
<MediaUploader editor={editor} />
<MentionPopup editor={editor} />
</div>
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
<button
type="button"
onClick={() => submit()}
disabled={editor?.isEmpty}
className="inline-flex h-9 w-20 items-center justify-center rounded-lg bg-blue-500 px-2 font-medium text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading === true ? (
<LoaderIcon className="h-5 w-5 animate-spin" />
) : (
"Post"
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { useArk, useStorage } from "@lume/ark";
import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { getPublicKey, nip19 } from "nostr-tools";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export function NewPrivkeyScreen() {
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const [nsec, setNsec] = useState("");
const submit = async (isSave?: boolean) => {
try {
if (!nsec.startsWith("nsec1"))
return toast.info("You must enter a private key starts with nsec");
const decoded = nip19.decode(nsec);
if (decoded.type !== "nsec")
return toast.info("You must enter a valid nsec");
const privkey = decoded.data;
const pubkey = getPublicKey(privkey);
if (pubkey !== storage.account.pubkey)
return toast.info(
"Your nsec is not match your current public key, please make sure you enter right nsec",
);
const signer = new NDKPrivateKeySigner(privkey);
ark.updateNostrSigner({ signer });
if (isSave) await storage.createPrivkey(storage.account.pubkey, privkey);
navigate(-1);
} catch (e) {
toast.error(e);
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mb-16 flex flex-col gap-3">
<h1 className="text-center font-semibold text-neutral-900 dark:text-neutral-100">
You need to provide private key to sign nostr event.
</h1>
<input
name="privkey"
placeholder="nsec..."
type="password"
value={nsec}
onChange={(e) => setNsec(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
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 className="mt-2 flex flex-col gap-2">
<button
type="button"
onClick={() => submit()}
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"
>
Submit
</button>
<button
type="button"
onClick={() => submit(true)}
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
Submit and Save
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { useStorage } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { useState } from "react";
import { toast } from "sonner";
export function NWCForm({ setWalletConnectURL }) {
const storage = useStorage();
const [uri, setUri] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
try {
setLoading(true);
if (!uri.startsWith("nostr+walletconnect:")) {
toast.error(
"Connect URI is required and must start with format nostr+walletconnect:, please check again",
);
setLoading(false);
return;
}
const uriObj = new URL(uri);
const params = new URLSearchParams(uriObj.search);
if (params.has("relay") && params.has("secret")) {
await storage.createPrivkey(`${storage.account.pubkey}-nwc`, uri);
setWalletConnectURL(uri);
setLoading(false);
} else {
setLoading(false);
toast.error("Connect URI is not valid, please check again");
return;
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="flex flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<textarea
name="walletConnectURL"
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="nostr+walletconnect://"
className="h-40 w-full resize-none rounded-lg border-transparent bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={submit}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : "Connect"}
</button>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { useStorage } from "@lume/ark";
import { CheckCircleIcon } from "@lume/icons";
import { useEffect, useState } from "react";
import { NWCForm } from "./components/form";
export function NWCScreen() {
const storage = useStorage();
const [walletConnectURL, setWalletConnectURL] = useState<null | string>(null);
const remove = async () => {
await storage.removePrivkey(`${storage.account.pubkey}-nwc`);
setWalletConnectURL(null);
};
useEffect(() => {
async function getNWC() {
const nwc = await storage.loadPrivkey(`${storage.account.pubkey}-nwc`);
if (nwc) setWalletConnectURL(nwc);
}
getNWC();
}, []);
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex w-full flex-col gap-5">
<div className="text-center">
<h3 className="text-2xl font-bold leading-tight">
Nostr Wallet Connect
</h3>
<p className="leading-tight text-neutral-600 dark:text-neutral-400">
Sending zap easily via Bitcoin Lightning.
</p>
</div>
<div className="mx-auto max-w-lg">
{!walletConnectURL ? (
<NWCForm setWalletConnectURL={setWalletConnectURL} />
) : (
<div className="flex w-full flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<div className="flex items-center justify-center gap-1.5 text-sm text-teal-500">
<CheckCircleIcon className="h-4 w-4" />
<div>You&apos;re using nostr wallet connect</div>
</div>
<div className="flex flex-col gap-3">
<textarea
readOnly
value={`${walletConnectURL.substring(0, 120)}****`}
className="h-40 w-full resize-none rounded-lg border-transparent bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => remove()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-neutral-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:bg-neutral-800 dark:text-neutral-100"
>
Remove connection
</button>
</div>
</div>
)}
<div className="mt-5 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
Introduction
</h5>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Nostr Wallet Connect (NWC) is a way for applications like Nostr
clients to access a remote Lightning wallet through a
standardized protocol.
</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
To learn more about the details have a look at{" "}
<a
href="https://github.com/nostr-protocol/nips/blob/master/47.md"
target="_blank"
className="text-blue-500"
rel="noreferrer"
>
the specs (NIP47)
</a>
</p>
</div>
<div className="flex flex-col gap-1.5">
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
About zapping
</h5>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Lume doesn&apos;t take any commission or platform fees when you
zap someone.
</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Lume doesn&apos;t hold your Bitcoin
</p>
</div>
<div className="flex flex-col gap-1.5">
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
Recommend wallet that support NWC
</h5>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Mutiny Wallet:{" "}
<a
href="https://www.mutinywallet.com/"
target="_blank"
rel="noreferrer"
className="text-blue-500"
>
website
</a>
</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Self hosted NWC on Umbrel :{" "}
<a
href="https://apps.umbrel.com/app/alby-nostr-wallet-connect"
target="_blank"
rel="noreferrer"
className="text-blue-500"
>
website
</a>
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { NoteSkeleton, RepostNote, TextNote, useArk } from "@lume/ark";
import { ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { VList } from "virtua";
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["relay-events", relayUrl],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const url = `wss://${relayUrl}`;
const events = await ark.getRelayEvents({
relayUrl: url,
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
refetchOnWindowFocus: false,
});
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data],
);
const renderItem = useCallback(
(event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
},
[data],
);
return (
<VList className="mx-auto h-full w-full max-w-[500px] pt-10 scrollbar-none">
{status === "pending" ? (
<NoteSkeleton />
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
);
}

View File

@@ -0,0 +1,62 @@
import { useRelay } from "@lume/ark";
import { PlusIcon } from "@lume/icons";
import { NDKRelayUrl } from "@nostr-dev-kit/ndk";
import { normalizeRelayUrl } from "nostr-fetch";
import { useState } from "react";
import { toast } from "sonner";
const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/;
export function RelayForm() {
const { connectRelay } = useRelay();
const [relay, setRelay] = useState<{
url: NDKRelayUrl;
purpose: "read" | "write" | undefined;
}>({ url: "", purpose: undefined });
const create = () => {
if (relay.url.length < 1) return toast.info("Please enter relay url");
try {
const relayUrl = new URL(relay.url.replace(/\s/g, ""));
if (
domainRegex.test(relayUrl.host) &&
(relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:")
) {
connectRelay.mutate(normalizeRelayUrl(relay.url));
setRelay({ url: "", purpose: undefined });
} else {
return toast.error(
"URL is invalid, a relay must use websocket protocol (start with wss:// or ws://). Please check again",
);
}
} catch {
return toast.error("Relay URL is not valid. Please check again");
}
};
return (
<div className="flex flex-col gap-1">
<div className="flex gap-2">
<input
className="h-11 flex-1 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"
placeholder="wss://"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
value={relay.url}
onChange={(e) =>
setRelay((prev) => ({ ...prev, url: e.target.value }))
}
/>
<button
type="button"
onClick={() => create()}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-blue-500 text-white hover:bg-blue-600"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useArk, useRelay } from "@lume/ark";
import { LoaderIcon, PlusIcon, ShareIcon } from "@lume/icons";
import { User } from "@lume/ui";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { VList } from "virtua";
export function RelayList() {
const ark = useArk();
const { connectRelay } = useRelay();
const { status, data } = useQuery({
queryKey: ["relays"],
queryFn: async () => {
return await ark.getAllRelaysFromContacts();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
const navigate = useNavigate();
const inspectRelay = (relayUrl: string) => {
const url = new URL(relayUrl);
navigate(`/relays/${url.hostname}`);
};
return (
<div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900">
{status === "pending" ? (
<div className="flex h-full w-full items-center justify-center pb-10">
<div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
<p>Loading relay...</p>
</div>
</div>
) : (
<VList className="h-full">
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
<h3 className="font-semibold">Relay discovery</h3>
</div>
{[...data].map(([key, value]) => (
<div
key={key}
className="flex h-14 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900"
>
<div className="inline-flex items-center gap-2 divide-x divide-neutral-100 dark:divide-neutral-900">
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={() => inspectRelay(key)}
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-neutral-200 px-1.5 text-sm font-medium text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
<ShareIcon className="h-3 w-3" />
Inspect
</button>
<button
type="button"
onClick={() => connectRelay.mutate(key)}
className="inline-flex h-6 w-6 items-center justify-center rounded text-neutral-900 hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<PlusIcon className="h-3 w-3" />
</button>
</div>
<div className="inline-flex items-center gap-2 pl-3">
<span className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">
Relay:{" "}
</span>
<span className="max-w-[200px] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{key}
</span>
</div>
</div>
<div className="isolate flex -space-x-2">
{value.slice(0, 4).map((item) => (
<User key={item} pubkey={item} variant="stacked" />
))}
{value.length > 4 ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-700">
<span className="text-xs font-medium">+{value.length}</span>
</div>
) : null}
</div>
</div>
))}
</VList>
)}
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useArk, useStorage } from "@lume/ark";
import { CancelIcon, RefreshIcon } from "@lume/icons";
import { useRelay } from "@lume/utils";
import { NDKKind } from "@nostr-dev-kit/ndk";
import { useQuery } from "@tanstack/react-query";
import { RelayForm } from "./relayForm";
export function UserRelayList() {
const ark = useArk();
const storage = useStorage();
const { removeRelay } = useRelay();
const { status, data, refetch } = useQuery({
queryKey: ["relays", storage.account.pubkey],
queryFn: async () => {
const event = await ark.getEventByFilter({
filter: {
kinds: [NDKKind.RelayList],
authors: [storage.account.pubkey],
},
});
if (!event) return [];
return event.tags;
},
refetchOnWindowFocus: false,
});
const currentRelays = new Set(
ark.ndk.pool.connectedRelays().map((item) => item.url),
);
return (
<div className="col-span-1">
<div className="inline-flex h-16 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
<h3 className="font-semibold">Connected relays</h3>
<button
type="button"
onClick={() => refetch()}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<RefreshIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex flex-col gap-2 px-3">
{status === "pending" ? (
<p>Loading...</p>
) : !data.length ? (
<div className="flex h-20 w-full items-center justify-center rounded-xl bg-neutral-50 dark:bg-neutral-950">
<p className="text-sm font-medium">
You not have personal relay list yet
</p>
</div>
) : (
data.map((item) => (
<div
key={item[1]}
className="group flex h-11 items-center justify-between rounded-lg bg-neutral-100 px-3 dark:bg-neutral-900"
>
<div className="inline-flex items-baseline gap-2">
{currentRelays.has(item[1]) ? (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-teal-500" />
</span>
) : (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500" />
</span>
)}
<p className="max-w-[20rem] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{item[1]}
</p>
</div>
<div className="inline-flex items-center gap-2">
{item[2]?.length ? (
<div className="inline-flex h-6 w-max items-center justify-center rounded bg-neutral-200 px-2 text-xs font-medium capitalize dark:bg-neutral-800">
{item[2]}
</div>
) : null}
<button
type="button"
onClick={() => removeRelay.mutate(item[1])}
className="hidden h-6 w-6 items-center justify-center rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<CancelIcon className="h-4 w-4 text-neutral-900 dark:text-neutral-100" />
</button>
</div>
</div>
))
)}
<RelayForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { RelayList } from "./components/relayList";
import { UserRelayList } from "./components/userRelayList";
export function RelaysScreen() {
return (
<div className="grid h-full w-full grid-cols-3">
<RelayList />
<UserRelayList />
</div>
);
}

View File

@@ -0,0 +1,178 @@
import { ArrowLeftIcon, LoaderIcon } from "@lume/icons";
import { NIP11 } from "@lume/types";
import { User } from "@lume/ui";
import { Suspense } from "react";
import { Await, useLoaderData, useNavigate, useParams } from "react-router-dom";
import { RelayEventList } from "./components/relayEventList";
export function RelayScreen() {
const { url } = useParams();
const data: { relay?: { [key: string]: string } } = useLoaderData();
const navigate = useNavigate();
const getSoftwareName = (url: string) => {
const filename = url.substring(url.lastIndexOf("/") + 1);
return filename.replace(".git", "");
};
const titleCase = (s: string) => {
return s
.replace(/^[-_]*(.)/, (_, c) => c.toUpperCase())
.replace(/[-_]+(.)/g, (_, c) => ` ${c.toUpperCase()}`);
};
return (
<div className="grid h-full w-full grid-cols-3">
<div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900">
<div className="inline-flex h-16 w-full items-center gap-2.5 border-b border-neutral-100 px-3 dark:border-neutral-900">
<button type="button" onClick={() => navigate(-1)}>
<ArrowLeftIcon className="h-5 w-5 text-neutral-500 hover:text-neutral-600 dark:text-neutral-600 dark:hover:text-neutral-500" />
</button>
<h3 className="font-semibold text-neutral-950 dark:text-neutral-50">
Global events
</h3>
</div>
<RelayEventList relayUrl={url} />
</div>
<div className="col-span-1">
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">
Information
</h3>
</div>
<div className="mt-4 px-3">
<Suspense
fallback={
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900 dark:text-neutral-100">
<LoaderIcon className="h-4 w-4 animate-spin" />
Loading...
</div>
}
>
<Await
resolve={data.relay}
errorElement={
<div className="text-sm font-medium">
<p>Could not load relay information 😬</p>
</div>
}
>
{(resolvedRelay: NIP11) => (
<div className="flex flex-col gap-5">
<div>
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
{resolvedRelay.name}
</h3>
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-500">
{resolvedRelay.description}
</p>
</div>
{resolvedRelay.pubkey ? (
<div className="flex flex-col gap-1">
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Owner:
</h5>
<div className="w-full rounded-lg bg-neutral-100 px-2 py-2 dark:bg-neutral-900">
<User pubkey={resolvedRelay.pubkey} variant="simple" />
</div>
</div>
) : null}
{resolvedRelay.contact ? (
<div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Contact:
</h5>
<a
href={`mailto:${resolvedRelay.contact}`}
target="_blank"
className="underline after:content-['_↗'] hover:text-blue-600"
rel="noreferrer"
>
mailto:{resolvedRelay.contact}
</a>
</div>
) : null}
<div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Software:
</h5>
<a
href={resolvedRelay.software}
target="_blank"
rel="noreferrer"
className="underline after:content-['_↗'] hover:text-blue-600"
>
{`${getSoftwareName(resolvedRelay.software)} - ${
resolvedRelay.version
}`}
</a>
</div>
<div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Supported NIPs:
</h5>
<div className="mt-2 grid grid-cols-7 gap-2">
{resolvedRelay.supported_nips.map((item) => (
<a
key={item}
href={`https://nips.be/${item}`}
target="_blank"
rel="noreferrer"
className="inline-flex aspect-square h-auto w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
>
{item}
</a>
))}
</div>
</div>
{resolvedRelay.limitation ? (
<div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Limitation
</h5>
<div className="flex flex-col gap-2 divide-y divide-white/5">
{Object.keys(resolvedRelay.limitation).map(
(key, index) => {
return (
<div
key={key + index}
className="flex items-baseline justify-between pt-2"
>
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{titleCase(key)}:
</p>
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
{resolvedRelay.limitation[key].toString()}
</p>
</div>
);
},
)}
</div>
</div>
) : null}
{resolvedRelay.payments_url ? (
<div className="flex flex-col gap-1">
<a
href={resolvedRelay.payments_url}
target="_blank"
rel="noreferrer"
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium hover:bg-blue-600"
>
Open payment website
</a>
<span className="text-center text-xs text-neutral-600 dark:text-neutral-400">
You need to make a payment to connect this relay
</span>
</div>
) : null}
</div>
)}
</Await>
</Suspense>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { getVersion } from '@tauri-apps/api/app';
import { relaunch } from '@tauri-apps/plugin-process';
import { Update, check } from '@tauri-apps/plugin-updater';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
export function AboutScreen() {
const [version, setVersion] = useState('');
const [newUpdate, setNewUpdate] = useState<Update>(null);
const checkUpdate = async () => {
const update = await check();
if (!update) toast.info('There is no update available');
setNewUpdate(update);
};
const installUpdate = async () => {
await newUpdate.downloadAndInstall();
await relaunch();
};
useEffect(() => {
async function loadVersion() {
const appVersion = await getVersion();
setVersion(appVersion);
}
loadVersion();
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex items-center justify-center gap-2">
<img src="/icon.png" alt="Lume's logo" className="w-16 shrink-0" />
<div>
<h1 className="text-xl font-semibold">Lume</h1>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Version {version}
</p>
</div>
</div>
<div className="mx-auto mt-4 flex w-full max-w-xs flex-col gap-2">
{!newUpdate ? (
<button
type="button"
onClick={() => checkUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
>
Check for update
</button>
) : (
<button
type="button"
onClick={() => installUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
>
Install {newUpdate.version}
</button>
)}
<Link
to="https://lume.nu"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Website
</Link>
<Link
to="https://github.com/luminous-devs/lume/issues"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Report a issue
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useStorage } from "@lume/ark";
export function AdvancedSettingScreen() {
const storage = useStorage();
const clearCache = async () => {
await storage.clearCache();
};
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex flex-col gap-6">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">
Caches
</div>
<div className="text-sm">Use for boost up NDK</div>
</div>
<button
type="button"
onClick={() => clearCache()}
className="h-8 w-max rounded-lg bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
>
Clear
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { useStorage } from "@lume/ark";
import { EyeOffIcon } from "@lume/icons";
import { nip19 } from "nostr-tools";
import { useEffect, useState } from "react";
export function BackupSettingScreen() {
const storage = useStorage();
const [privkey, setPrivkey] = useState(null);
const [showPassword, setShowPassword] = useState(false);
const removePrivkey = async () => {
await storage.removePrivkey(storage.account.pubkey);
};
useEffect(() => {
async function loadPrivkey() {
const key = await storage.loadPrivkey(storage.account.pubkey);
if (key) setPrivkey(key);
}
loadPrivkey();
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div className="mb-2 text-sm font-semibold">Private key</div>
<div>
{!privkey ? (
<div className="inline-flex h-24 w-full items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
You&apos;ve stored private key on Lume
</div>
) : (
<>
<div className="relative">
<input
readOnly
type={showPassword ? "text" : "password"}
value={nip19.nsecEncode(privkey)}
className="relative h-11 w-full resize-none rounded-lg border-none bg-neutral-200 py-1 pl-3 pr-11 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-1.5 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 transform items-center justify-center rounded-lg bg-neutral-50 dark:bg-neutral-950"
>
<EyeOffIcon className="h-4 w-4" />
</button>
</div>
<button
type="button"
onClick={() => removePrivkey()}
className="mt-2 inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-red-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:hover:text-white"
>
Remove private key
</button>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { useArk } from "@lume/ark";
import { EditIcon, LoaderIcon } from "@lume/icons";
import { compactNumber } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
export function ContactCard() {
const ark = useArk();
const { status, data } = useQuery({
queryKey: ["contacts"],
queryFn: async () => {
const contacts = await ark.getUserContacts({});
return contacts;
},
refetchOnWindowFocus: false,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === "pending" ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data.length)}
</h3>
<div className="mt-auto flex h-6 w-full items-center justify-between">
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
Contacts
</p>
<Link
to="/settings/edit-contact"
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
<EditIcon className="h-3 w-3" />
Edit
</Link>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { useStorage } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { compactNumber } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/plugin-http";
import { Link } from "react-router-dom";
export function PostCard() {
const storage = useStorage();
const { status, data } = useQuery({
queryKey: ["user-stats", storage.account.pubkey],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${storage.account.pubkey}`,
{
signal,
},
);
if (!res.ok) {
throw new Error("Error");
}
return await res.json();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === "pending" ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format(
data.stats[storage.account.pubkey].pub_note_count,
)}
</h3>
<div className="mt-auto flex h-6 w-full items-center justify-between">
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
Posts
</p>
<Link
to={`/users/${storage.account.pubkey}`}
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
View
</Link>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { useProfile, useStorage } from "@lume/ark";
import { EditIcon, LoaderIcon } from "@lume/icons";
import { displayNpub } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { minidenticon } from "minidenticons";
import { nip19 } from "nostr-tools";
import { Link } from "react-router-dom";
export function ProfileCard() {
const storage = useStorage();
const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(storage.account.pubkey, 90, 50),
)}`;
const { isLoading, user } = useProfile(storage.account.pubkey);
const copyNpub = async () => {
return await writeText(nip19.npubEncode(storage.account.pubkey));
};
return (
<div className="mb-4 h-56 w-full rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<div className="flex h-10 w-full justify-end gap-3">
<button
type="button"
onClick={copyNpub}
className="inline-flex h-8 w-28 transform items-center justify-center gap-1.5 rounded-full bg-neutral-200 text-sm font-medium hover:bg-neutral-400 active:translate-y-1 dark:bg-neutral-800 dark:hover:bg-neutral-600"
>
Copy NPUB
</button>
<Link
to="/settings/edit-profile"
className="inline-flex h-8 w-20 items-center justify-center gap-1.5 rounded-full bg-neutral-200 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-600"
>
<EditIcon className="h-4 w-4" />
Edit
</Link>
</div>
<div className="flex flex-col gap-2.5">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={storage.account.pubkey}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="h-16 w-16 rounded-xl border border-neutral-200/50 shadow-[rgba(17,_17,_26,_0.1)_0px_0px_16px] dark:border-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={storage.account.pubkey}
className="h-16 w-16 rounded-xl bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div>
<h3 className="text-3xl font-semibold leading-8 text-neutral-900 dark:text-neutral-100">
{user?.display_name || user?.name}
</h3>
<p className="text-lg text-neutral-700 dark:text-neutral-300">
{user?.nip05 || displayNpub(storage.account.pubkey, 16)}
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { useArk, useStorage } from "@lume/ark";
import { EditIcon, LoaderIcon } from "@lume/icons";
import { compactNumber } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
export function RelayCard() {
const ark = useArk();
const storage = useStorage();
const { status, data } = useQuery({
queryKey: ["relays", storage.account.pubkey],
queryFn: async () => {
const relays = await ark.getUserRelays({});
return relays;
},
refetchOnWindowFocus: false,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === "pending" ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data?.relays?.length || 0)}
</h3>
<div className="mt-auto flex h-6 w-full items-center justify-between">
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
Relays
</p>
<Link
to="/relays"
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
<EditIcon className="h-3 w-3" />
Edit
</Link>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { useStorage } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { compactNumber } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/plugin-http";
export function ZapCard() {
const storage = useStorage();
const { status, data } = useQuery({
queryKey: ["user-stats", storage.account.pubkey],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${storage.account.pubkey}`,
{
signal,
},
);
if (!res.ok) {
throw new Error("Error");
}
return await res.json();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === "pending" ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format(
data?.stats[storage.account.pubkey]?.zaps_received?.msats /
1000 || 0,
)}
</h3>
<div className="mt-auto flex h-6 items-center text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
Sats received
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { User } from "@lume/ui";
import { useQuery } from "@tanstack/react-query";
export function EditContactScreen() {
const ark = useArk();
const { status, data } = useQuery({
queryKey: ["contacts"],
queryFn: async () => {
return await ark.getUserContacts({});
},
refetchOnWindowFocus: false,
});
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-3">
{status === "pending" ? (
<div className="flex h-10 w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
data.map((item) => (
<div
key={item}
className="flex h-16 w-full items-center justify-between rounded-xl bg-neutral-100 px-2.5 dark:bg-neutral-900"
>
<User pubkey={item} variant="simple" />
</div>
))
)}
</div>
);
}

View File

@@ -0,0 +1,306 @@
import { useArk, useStorage } from "@lume/ark";
import {
CheckCircleIcon,
LoaderIcon,
PlusIcon,
UnverifiedIcon,
} from "@lume/icons";
import { NDKKind, NDKUserProfile } from "@nostr-dev-kit/ndk";
import { useQueryClient } from "@tanstack/react-query";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
export function EditProfileScreen() {
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState("");
const [banner, setBanner] = useState("");
const [nip05, setNIP05] = useState({ verified: true, text: "" });
const ark = useArk();
const storage = useStorage();
const {
register,
handleSubmit,
reset,
setError,
formState: { isValid, errors },
} = useForm({
defaultValues: async () => {
const res: NDKUserProfile = queryClient.getQueryData([
"user",
storage.account.pubkey,
]);
if (res.image) {
setPicture(res.image);
}
if (res.banner) {
setBanner(res.banner);
}
if (res.nip05) {
setNIP05((prev) => ({ ...prev, text: res.nip05 }));
}
return res;
},
});
const queryClient = useQueryClient();
const navigate = useNavigate();
const uploadAvatar = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
setLoading(true);
const image = await ark.upload({});
if (image) {
setPicture(image);
setLoading(false);
}
} catch (e) {
setLoading(false);
await message(`Upload failed, error: ${e}`, {
title: "Lume",
type: "error",
});
}
};
const uploadBanner = async () => {
try {
setLoading(true);
const image = await ark.upload({});
if (image) {
setBanner(image);
setLoading(false);
}
} catch (e) {
setLoading(false);
await message(`Upload failed, error: ${e}`, {
title: "Lume",
type: "error",
});
}
};
const onSubmit = async (data: NDKUserProfile) => {
// start loading
setLoading(true);
let content = {
...data,
username: data.name,
display_name: data.name,
bio: data.about,
image: data.picture,
};
if (data.nip05) {
const verify = ark.validateNIP05({
pubkey: storage.account.pubkey,
nip05: data.nip05,
});
if (verify) {
content = { ...content, nip05: data.nip05 };
} else {
setNIP05((prev) => ({ ...prev, verified: false }));
setError("nip05", {
type: "manual",
message: "Can't verify your Lume ID / NIP-05, please check again",
});
}
}
const publish = await ark.createEvent({
kind: NDKKind.Metadata,
tags: [],
content: JSON.stringify(content),
});
if (publish) {
// invalid cache
queryClient.invalidateQueries({
queryKey: ["user", storage.account.pubkey],
});
// reset form
reset();
// reset state
setLoading(false);
setPicture(null);
setBanner(null);
} else {
setLoading(false);
}
};
return (
<div className="mx-auto w-full max-w-md">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
<input type={"hidden"} {...register("picture")} value={picture} />
<input type={"hidden"} {...register("banner")} value={banner} />
<div className="flex flex-col items-center justify-center">
<div className="relative h-36 w-full">
{banner ? (
<img
src={banner}
alt="user's banner"
className="h-full w-full rounded-xl object-cover"
/>
) : (
<div className="h-full w-full rounded-xl bg-neutral-200 dark:bg-neutral-900" />
)}
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform overflow-hidden rounded-xl">
<button
type="button"
onClick={() => uploadBanner()}
className="inline-flex h-full w-full items-center justify-center bg-black/20 text-white"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="mb-5 px-4">
<div className="relative z-10 -mt-7 h-14 w-14 overflow-hidden rounded-xl ring-2 ring-white dark:ring-black">
<img
src={picture}
alt="user's avatar"
className="h-14 w-14 rounded-xl object-cover"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<button
type="button"
onClick={() => uploadAvatar()}
className="inline-flex h-full w-full items-center justify-center rounded-xl bg-black/50 text-white"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label
htmlFor="display_name"
className="text-sm font-semibold uppercase tracking-wider"
>
Display Name
</label>
<input
type={"text"}
{...register("display_name")}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider"
>
Name
</label>
<input
type={"text"}
{...register("name")}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="nip05"
className="text-sm font-semibold uppercase tracking-wider"
>
NIP-05
</label>
<div className="relative">
<input
{...register("nip05")}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 transform">
{nip05.verified ? (
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-teal-500 px-1 pr-1.5 text-xs font-medium text-white">
<CheckCircleIcon className="h-4 w-4" />
Verified
</span>
) : (
<span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 pl-1 pr-1.5 text-xs font-medium text-white">
<UnverifiedIcon className="h-4 w-4" />
Unverified
</span>
)}
</div>
{errors.nip05 && (
<p className="mt-1 text-sm text-red-400">
{errors.nip05.message.toString()}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider"
>
Website
</label>
<input
type={"text"}
{...register("website", { required: false })}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider"
>
Lightning address
</label>
<input
type={"text"}
{...register("lud16", { required: false })}
spellCheck={false}
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider"
>
Bio
</label>
<textarea
{...register("about")}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div>
<button
type="submit"
disabled={!isValid}
className="mx-auto inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
) : (
"Update"
)}
</button>
</div>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,285 @@
import { useStorage } from "@lume/ark";
import { DarkIcon, LightIcon, SystemModeIcon } from "@lume/icons";
import * as Switch from "@radix-ui/react-switch";
import { invoke } from "@tauri-apps/api/primitives";
import { getCurrent } from "@tauri-apps/api/window";
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
export function GeneralSettingScreen() {
const storage = useStorage();
const [settings, setSettings] = useState({
autoupdate: false,
autolaunch: false,
outbox: false,
media: true,
hashtag: true,
notification: true,
appearance: "system",
});
const changeTheme = async (theme: "light" | "dark" | "auto") => {
await invoke("plugin:theme|set_theme", { theme });
// update state
setSettings((prev) => ({ ...prev, appearance: theme }));
};
const toggleAutolaunch = async () => {
if (!settings.autolaunch) {
await enable();
// update state
setSettings((prev) => ({ ...prev, autolaunch: true }));
} else {
await disable();
// update state
setSettings((prev) => ({ ...prev, autolaunch: false }));
}
};
const toggleOutbox = async () => {
await storage.createSetting("outbox", String(+!settings.outbox));
// update state
setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
};
const toggleMedia = async () => {
await storage.createSetting("media", String(+!settings.media));
storage.settings.media = !settings.media;
// update state
setSettings((prev) => ({ ...prev, media: !settings.media }));
};
const toggleHashtag = async () => {
await storage.createSetting("hashtag", String(+!settings.hashtag));
storage.settings.hashtag = !settings.hashtag;
// update state
setSettings((prev) => ({ ...prev, hashtag: !settings.hashtag }));
};
const toggleAutoupdate = async () => {
await storage.createSetting("autoupdate", String(+!settings.autoupdate));
storage.settings.autoupdate = !settings.autoupdate;
// update state
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
};
const toggleNofitication = async () => {
if (settings.notification) return;
await requestPermission();
// update state
setSettings((prev) => ({ ...prev, notification: !settings.notification }));
};
useEffect(() => {
async function loadSettings() {
const theme = await getCurrent().theme();
setSettings((prev) => ({ ...prev, appearance: theme }));
const autostart = await isEnabled();
setSettings((prev) => ({ ...prev, autolaunch: autostart }));
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),
}));
if (item.key === "media")
setSettings((prev) => ({
...prev,
media: !!parseInt(item.value),
}));
if (item.key === "hashtag")
setSettings((prev) => ({
...prev,
hashtag: !!parseInt(item.value),
}));
}
}
loadSettings();
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex flex-col gap-6">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">
Updater
</div>
<div className="text-sm">Auto download new update at Login</div>
</div>
<Switch.Root
checked={settings.autoupdate}
onClick={() => toggleAutoupdate()}
className="relative h-7 w-12 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>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">
Startup
</div>
<div className="text-sm">Launch Lume at Login</div>
</div>
<Switch.Root
checked={settings.autolaunch}
onClick={() => toggleAutolaunch()}
className="relative h-7 w-12 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>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">
Gossip
</div>
<div className="text-sm">Use Outbox model</div>
</div>
<Switch.Root
checked={settings.outbox}
onClick={() => toggleOutbox()}
className="relative h-7 w-12 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>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">
Media
</div>
<div className="text-sm">Automatically load media</div>
</div>
<Switch.Root
checked={settings.media}
onClick={() => toggleMedia()}
className="relative h-7 w-12 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>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">
Hashtag
</div>
<div className="text-sm">Hide all hashtags in content</div>
</div>
<Switch.Root
checked={settings.hashtag}
onClick={() => toggleHashtag()}
className="relative h-7 w-12 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>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">
Notification
</div>
<div className="text-sm">Automatically send notification</div>
</div>
<Switch.Root
checked={settings.notification}
disabled={settings.notification}
onClick={() => toggleNofitication()}
className="relative h-7 w-12 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>
<div className="flex w-full items-start gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">
Appearance
</div>
<div className="flex flex-1 gap-6">
<button
type="button"
onClick={() => changeTheme("light")}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={twMerge(
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
settings.appearance === "light"
? "bg-blue-500 text-white"
: "bg-neutral-100 dark:bg-neutral-900",
)}
>
<LightIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Light
</p>
</button>
<button
type="button"
onClick={() => changeTheme("dark")}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={twMerge(
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
settings.appearance === "dark"
? "bg-blue-500 text-white"
: "bg-neutral-100 dark:bg-neutral-900",
)}
>
<DarkIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Dark
</p>
</button>
<button
type="button"
onClick={() => changeTheme("auto")}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={twMerge(
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
settings.appearance === "auto"
? "bg-blue-500 text-white"
: "bg-neutral-100 dark:bg-neutral-900",
)}
>
<SystemModeIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
System
</p>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { ContactCard } from "./components/contactCard";
import { PostCard } from "./components/postCard";
import { ProfileCard } from "./components/profileCard";
import { RelayCard } from "./components/relayCard";
import { ZapCard } from "./components/zapCard";
export function UserSettingScreen() {
return (
<div className="mx-auto w-full max-w-xl">
<ProfileCard />
<div className="grid grid-cols-2 gap-4">
<ContactCard />
<RelayCard />
<PostCard />
<ZapCard />
</div>
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { useArk, useProfile, useStorage } from "@lume/ark";
import { NIP05 } from "@lume/ui";
import { displayNpub } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { UserStats } from "./stats";
export function UserProfile({ pubkey }: { pubkey: string }) {
const ark = useArk();
const storage = useStorage();
const { user } = useProfile(pubkey);
const [followed, setFollowed] = useState(false);
const navigate = useNavigate();
const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(pubkey, 90, 50),
)}`;
const follow = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
setFollowed(true);
const add = await ark.createContact({ pubkey });
if (!add) {
toast.success("You already follow this user");
setFollowed(false);
}
} catch (e) {
toast.error(e);
setFollowed(false);
}
};
const unfollow = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
setFollowed(false);
await ark.deleteContact({ pubkey });
} catch (e) {
toast.error(e);
}
};
useEffect(() => {
if (storage.account.contacts.includes(pubkey)) {
setFollowed(true);
}
}, []);
if (!user) return <p>Loading...</p>;
return (
<>
<div className="h-56 w-full overflow-hidden rounded-tl-lg">
{user?.banner ? (
<img
src={user?.banner}
alt="user banner"
className="h-full w-full rounded-tl-lg object-cover"
/>
) : (
<div className="h-full w-full rounded-tl-lg bg-neutral-100 dark:bg-neutral-900" />
)}
</div>
<div className="-mt-7 flex w-full flex-col items-center px-5">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="h-14 w-14 rounded-lg bg-white object-cover ring-2 ring-neutral-100 dark:ring-neutral-900"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={pubkey}
className="h-14 w-14 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="mt-2 flex flex-1 flex-col gap-6">
<div className="flex flex-col items-center gap-1">
<div className="inline-flex flex-col items-center">
<h5 className="text-center text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name ||
user?.display_name ||
user?.displayName ||
"No name"}
</h5>
{user?.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user.nip05}
className="text-neutral-600 dark:text-neutral-400"
/>
) : (
<span className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-400">
{displayNpub(pubkey, 16)}
</span>
)}
</div>
<div className="flex flex-col gap-6">
{user?.about || user?.bio ? (
<p className="mt-2 max-w-[500px] select-text break-words text-center text-neutral-900 dark:text-neutral-100">
{user.about || user.bio}
</p>
) : (
<div />
)}
<UserStats pubkey={pubkey} />
</div>
</div>
<div className="inline-flex items-center justify-center gap-2">
{followed ? (
<button
type="button"
onClick={unfollow}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
>
Unfollow
</button>
) : (
<button
type="button"
onClick={follow}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
>
Follow
</button>
)}
<Link
to={`/chats/${pubkey}`}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
>
Message
</Link>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,84 @@
import { LoaderIcon } from "@lume/icons";
import { compactNumber } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/plugin-http";
export function UserStats({ pubkey }: { pubkey: string }) {
const { status, data } = useQuery({
queryKey: ["user-stats", pubkey],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${pubkey}`,
{
signal,
},
);
if (!res.ok) {
throw new Error("Error");
}
return await res.json();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
if (status === "pending") {
return (
<div className="flex w-full items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
</div>
);
}
if (status === "error") {
return <div className="flex w-full items-center justify-center" />;
}
return (
<div className="flex w-full items-center justify-center gap-10">
<div className="inline-flex flex-col items-center gap-1">
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data?.stats[pubkey]?.followers_pubkey_count) ??
0}
</span>
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
Followers
</span>
</div>
<div className="inline-flex flex-col items-center gap-1">
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
{compactNumber.format(
data?.stats[pubkey]?.pub_following_pubkey_count,
) ?? 0}
</span>
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
Following
</span>
</div>
<div className="inline-flex flex-col items-center gap-1">
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
{compactNumber.format(
data?.stats[pubkey]?.zaps_received?.msats / 1000 ?? 0,
)}
</span>
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
Zaps received
</span>
</div>
<div className="inline-flex flex-col items-center gap-1">
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
{compactNumber.format(
data?.stats[pubkey]?.zaps_sent?.msats / 1000 ?? 0,
)}
</span>
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
Zaps sent
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { NoteSkeleton, RepostNote, TextNote, useArk } from "@lume/ark";
import { ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import { UserProfile } from "./components/profile";
export function UserScreen() {
const { pubkey } = useParams();
const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["user-posts", pubkey],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: [pubkey],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
refetchOnWindowFocus: false,
});
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data],
);
// render event match event kind
const renderItem = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote key={event.id} event={event} />;
case NDKKind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<div className="relative h-full w-full overflow-y-auto">
<UserProfile pubkey={pubkey} />
<div className="mt-6 h-full w-full border-t border-neutral-100 px-1.5 dark:border-neutral-900">
<h3 className="mb-2 pt-4 text-center text-lg font-semibold leading-none text-neutral-900 dark:text-neutral-100">
Latest posts
</h3>
<div className="mx-auto flex h-full max-w-[500px] flex-col justify-between gap-1.5 pb-4 pt-1.5">
{status === "pending" ? (
<NoteSkeleton />
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import sharedConfig from "@lume/tailwindcss";
const config = {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/ark/**/*{.js,.ts,.jsx,.tsx}",
"../../packages/ui/**/*{.js,.ts,.jsx,.tsx}",
"index.html",
],
presets: [sharedConfig],
};
export default config;

View File

@@ -0,0 +1,8 @@
{
"extends": "@lume/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,18 @@
import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite';
import viteTsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), viteTsconfigPaths()],
envPrefix: ['VITE_', 'TAURI_'],
build: {
target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_DEBUG,
},
server: {
strictPort: true,
port: 3000,
},
clearScreen: false,
});

0
apps/web/.keep Normal file
View File