feat: settings screens

This commit is contained in:
reya 2024-04-02 13:19:26 +07:00
parent 09aa2ecafc
commit 89bb8d88f6
20 changed files with 419 additions and 74 deletions

View File

@ -37,6 +37,7 @@ const router = createRouter({
ark: undefined!,
platform: platformName,
locale: osLocale,
settings: null,
queryClient,
},
});

View File

@ -7,12 +7,14 @@ import {
import { type Ark } from "@lume/ark";
import { type QueryClient } from "@tanstack/react-query";
import { type Platform } from "@tauri-apps/plugin-os";
import { Settings } from "@lume/types";
interface RouterContext {
ark: Ark;
queryClient: QueryClient;
platform: Platform;
locale: string;
settings: Settings;
}
export const Route = createRootRouteWithContext<RouterContext>()({

View File

@ -68,7 +68,7 @@ function Screen() {
</div>
<div className="flex w-full flex-col gap-5">
<div className="flex flex-col gap-2">
<label htmlFor="nsec" className="font-medium">
<label htmlFor="passphase" className="font-medium">
Set a passphase to secure your key
</label>
<div className="relative">

View File

@ -1,8 +1,7 @@
import { CheckIcon } from "@lume/icons";
import { createLazyFileRoute } from "@tanstack/react-router";
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import * as Switch from "@radix-ui/react-switch";
import { Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { Settings } from "@lume/types";
import { useArk } from "@lume/ark";
@ -18,6 +17,7 @@ export const Route = createLazyFileRoute("/auth/settings")({
function Screen() {
const ark = useArk();
const navigate = useNavigate();
// @ts-ignore, magic!!!
const { account } = Route.useSearch();
@ -51,10 +51,12 @@ function Screen() {
}));
};
const saveSettings = async () => {
const submit = async () => {
try {
const eventId = await ark.set_settings(settings);
if (eventId) toast.success("Settings have been updated successfully.");
if (eventId) {
navigate({ to: "/$account/home", params: { account }, replace: true });
}
} catch (e) {
toast.error(e);
}
@ -142,22 +144,13 @@ function Screen() {
</p>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={saveSettings}
className="inline-flex h-11 flex-1 items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 disabled:opacity-50 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Save settings
</button>
<Link
to="/$account/home"
params={{ account }}
className="inline-flex h-11 flex-1 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{t("global.continue")}
</Link>
</div>
<button
type="button"
onClick={submit}
className="inline-flex h-11 flex-1 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{t("global.continue")}
</button>
</div>
</div>
);

View File

@ -24,6 +24,10 @@ export const Route = createFileRoute("/")({
const account = accounts[0].npub;
const loadedAccount = await ark.load_selected_account(account);
const settings = await ark.get_settings(account);
// Update settings
context.settings = settings;
if (loadedAccount) {
throw redirect({
@ -43,12 +47,15 @@ export const Route = createFileRoute("/")({
function Screen() {
const ark = useArk();
const navigate = useNavigate();
const context = Route.useRouteContext();
const [loading, setLoading] = useState(false);
const select = async (npub: string) => {
setLoading(true);
const loadAccount = await ark.load_selected_account(npub);
context.settings = await ark.get_settings(npub);
if (loadAccount) {
navigate({
to: "/$account/home",

View File

@ -0,0 +1,106 @@
import { SettingsIcon, UserIcon, ZapIcon, SecureIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/settings")({
component: Screen,
});
function Screen() {
const { t } = useTranslation();
return (
<div className="flex h-full w-full flex-col bg-neutral-100 dark:bg-neutral-950">
<div
data-tauri-drag-region
className="flex h-20 w-full shrink-0 items-center justify-center border-b border-neutral-200 dark:border-neutral-800"
>
<div className="flex items-center gap-1">
<Link to="/settings/general">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
: "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800",
)}
>
<SettingsIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.general.title")}
</p>
</div>
);
}}
</Link>
<Link to="/settings/user">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
: "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800",
)}
>
<UserIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.user.title")}
</p>
</div>
);
}}
</Link>
<Link to="/settings/zap">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
: "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800",
)}
>
<ZapIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.zap.title")}
</p>
</div>
);
}}
</Link>
<Link to="/settings/backup">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
: "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800",
)}
>
<SecureIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.backup.title")}
</p>
</div>
);
}}
</Link>
</div>
</div>
<div className="w-full flex-1 overflow-y-auto px-5 py-4">
<div className="mx-auto w-full max-w-xl">
<Outlet />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,122 @@
import { type Account } from "@lume/types";
import { User } from "@lume/ui";
import { displayNsec } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/backup")({
component: Screen,
loader: async ({ context }) => {
const ark = context.ark;
const npubs = await ark.get_all_accounts();
let accounts: Account[] = [];
for (const account of npubs) {
const nsec: string = await invoke("get_stored_nsec", {
npub: account.npub,
});
accounts.push({ ...account, nsec });
}
return accounts;
},
});
function Screen() {
const accounts = Route.useLoaderData();
return (
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
{accounts.map((account, index) => (
<div key={account.npub} className="flex items-start gap-6 py-3">
<div className="w-36 shrink-0 text-end font-medium">
Account {index}
</div>
<Account account={account} />
</div>
))}
</div>
);
}
function Account({ account }: { account: Account }) {
const [key, setKey] = useState(account.nsec);
const [copied, setCopied] = useState(false);
const [passphase, setPassphase] = useState("");
const encrypt = async () => {
const encrypted: string = await invoke("get_encrypted_key", {
npub: account.npub,
password: passphase,
});
setKey(encrypted);
};
const copyKey = async () => {
try {
await writeText(key);
setCopied(true);
} catch (e) {
toast.error(e);
}
};
return (
<div className="flex flex-1 flex-col gap-2">
<User.Provider pubkey={account.npub}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="size-8 rounded-full object-cover" />
<div className="flex flex-col">
<User.Name className="text-sm leading-tight" />
<User.NIP05 className="text-sm leading-tight text-neutral-700 dark:text-neutral-300" />
</div>
</User.Root>
</User.Provider>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
readOnly
type="text"
value={displayNsec(key, 36)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={copyKey}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="passphase"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Set a passphase to secure your key
</label>
<div className="flex items-center gap-2">
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={encrypt}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Update
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/settings/general')({
component: () => <div>Hello /settings/general!</div>
})

View File

@ -1,9 +0,0 @@
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/settings/")({
component: Screen,
});
function Screen() {
return <div>Settings</div>;
}

View File

@ -0,0 +1,5 @@
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/settings/user')({
component: () => <div>Hello /settings/user!</div>
})

View File

@ -0,0 +1,96 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/settings/zap")({
component: Screen,
});
function Screen() {
return (
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
<div className="flex flex-col gap-6 py-3">
<Connection />
<DefaultAmount />
</div>
</div>
);
}
function Connection() {
const [uri, setUri] = useState("");
const connect = async () => {
try {
await invoke("set_nwc", { uri });
} catch (e) {
toast.error(String(e));
}
};
return (
<div className="flex items-start gap-6">
<div className="w-36 shrink-0 text-end font-medium">Connection</div>
<div className="flex-1">
<div className="flex w-full flex-col gap-1">
<label
htmlFor="nwc"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Nostr Wallet Connect
</label>
<div className="flex items-center gap-2">
<input
name="nwc"
type="text"
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://"
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={connect}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Connect
</button>
</div>
</div>
</div>
</div>
);
}
function DefaultAmount() {
return (
<div className="flex items-start gap-6">
<div className="w-36 shrink-0 text-end font-medium">Default amount</div>
<div className="flex-1">
<div className="flex w-full flex-col gap-1">
<label
htmlFor="amount"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Set default amount for quick zapping
</label>
<div className="flex items-center gap-2">
<input
name="amount"
type="number"
value={21}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Update
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -527,8 +527,13 @@ export class Ark {
const settings: Settings = JSON.parse(cmd);
return settings;
} catch (e) {
throw new Error(e);
} catch {
const defaultSettings: Settings = {
autoUpdate: false,
enhancedPrivacy: false,
notification: false,
};
return defaultSettings;
}
}

View File

@ -7,15 +7,15 @@ export function SettingsIcon(
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
stroke="currentColor"
strokeLinecap="square"
strokeLinejoin="round"
strokeWidth="1.5"
d="M11.002 3.325a2 2 0 0 1 1.996 0l6.25 3.598a2 2 0 0 1 1.002 1.733v6.688a2 2 0 0 1-1.002 1.733l-6.25 3.598a2 2 0 0 1-1.996 0l-6.25-3.598a2 2 0 0 1-1.002-1.733V8.656a2 2 0 0 1 1.002-1.733l6.25-3.598Z"
d="m7.878 5.214-.703-.162a1.77 1.77 0 0 0-2.123 2.123l.162.703a2 2 0 0 1-.84 2.114l-.854.57a1.728 1.728 0 0 0 0 2.876l.855.57a2 2 0 0 1 .84 2.114l-.163.703a1.77 1.77 0 0 0 2.123 2.123l.703-.162a2 2 0 0 1 2.114.84l.57.854a1.728 1.728 0 0 0 2.876 0l.57-.855a2 2 0 0 1 2.114-.84l.703.163a1.77 1.77 0 0 0 2.123-2.123l-.162-.703a2 2 0 0 1 .84-2.114l.854-.57a1.728 1.728 0 0 0 0-2.876l-.855-.57a2 2 0 0 1-.84-2.114l.163-.703a1.77 1.77 0 0 0-2.123-2.123l-.703.162a2 2 0 0 1-2.114-.84l-.57-.854a1.728 1.728 0 0 0-2.876 0l-.57.855a2 2 0 0 1-2.114.84Z"
/>
<path
stroke="currentColor"
strokeLinecap="square"
strokeLinejoin="round"
strokeWidth="1.5"
d="M15.25 12a3.25 3.25 0 1 1-6.5 0 3.25 3.25 0 0 1 6.5 0Z"
d="M14.75 12a2.75 2.75 0 1 1-5.5 0 2.75 2.75 0 0 1 5.5 0Z"
/>
</svg>
);

View File

@ -1,24 +1,16 @@
import { SVGProps } from "react";
export function UserIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M18.995 19.147C18.893 17.393 17.367 16 15.5 16h-7c-1.867 0-3.393 1.393-3.495 3.147m13.99 0A9.97 9.97 0 0022 12c0-5.523-4.477-10-10-10S2 6.477 2 12a9.97 9.97 0 003.005 7.147m13.99 0A9.967 9.967 0 0112 22a9.967 9.967 0 01-6.995-2.853M15 10a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.5"
d="M5.857 18.916C7.171 16.996 9.332 15.75 12 15.75c2.668 0 4.83 1.247 6.143 3.166m-12.286 0A9.215 9.215 0 0 0 12 21.25c2.358 0 4.51-.882 6.143-2.334m-12.286 0a9.25 9.25 0 1 1 12.286 0M15.25 10a3.25 3.25 0 1 1-6.5 0 3.25 3.25 0 0 1 6.5 0Z"
/>
</svg>
);
}

View File

@ -56,6 +56,7 @@ export interface Contact {
export interface Account {
npub: string;
nsec?: string;
contacts?: string[];
interests?: Interests;
}

View File

@ -1,4 +1,4 @@
import { Kind } from "@lume/types";
import { Kind, Settings } from "@lume/types";
import {
AUDIOS,
IMAGES,
@ -17,6 +17,7 @@ import { Hashtag } from "./mentions/hashtag";
import { VideoPreview } from "./preview/video";
import { ImagePreview } from "./preview/image";
import reactStringReplace from "react-string-replace";
import { useRouteContext } from "@tanstack/react-router";
export function NoteContent({
compact = true,
@ -25,6 +26,7 @@ export function NoteContent({
compact?: boolean;
className?: string;
}) {
const settings: Settings = useRouteContext({ strict: false });
const event = useNoteContext();
const content = useMemo(() => {
const text = event.content.trim();
@ -81,16 +83,18 @@ export function NoteContent({
const url = new URL(match);
const ext = url.pathname.split(".")[1];
if (IMAGES.includes(ext)) {
return <ImagePreview key={match + i} url={url.toString()} />;
}
if (!settings.enhancedPrivacy) {
if (IMAGES.includes(ext)) {
return <ImagePreview key={match + i} url={url.toString()} />;
}
if (VIDEOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
if (VIDEOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
if (AUDIOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
if (AUDIOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
}
return (

View File

@ -234,7 +234,7 @@
}
},
"user": {
"title": "User"
"title": "Account"
},
"zap": {
"title": "Zap",

View File

@ -103,6 +103,7 @@ fn main() {
nostr::keys::create_keys,
nostr::keys::save_key,
nostr::keys::get_encrypted_key,
nostr::keys::get_stored_nsec,
nostr::keys::verify_signer,
nostr::keys::load_selected_account,
nostr::keys::event_to_bech32,

View File

@ -103,6 +103,17 @@ pub fn get_encrypted_key(npub: &str, password: &str) -> Result<String, String> {
}
}
#[tauri::command]
pub fn get_stored_nsec(npub: &str) -> Result<String, String> {
let keyring = Entry::new("Lume Secret Storage", npub).unwrap();
if let Ok(nsec) = keyring.get_password() {
Ok(nsec)
} else {
Err("Key not found".into())
}
}
#[tauri::command]
pub async fn load_selected_account(npub: &str, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;

View File

@ -60,15 +60,18 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
println!("todo!")
}
"settings" => {
let _ =
WebviewWindowBuilder::new(app, "settings", WebviewUrl::App(PathBuf::from("settings")))
.title("Editor")
.min_inner_size(600., 500.)
.inner_size(800., 500.)
.hidden_title(true)
.title_bar_style(TitleBarStyle::Overlay)
.build()
.unwrap();
let _ = WebviewWindowBuilder::new(
app,
"settings",
WebviewUrl::App(PathBuf::from("settings/general")),
)
.title("Editor")
.min_inner_size(600., 500.)
.inner_size(800., 500.)
.hidden_title(true)
.title_bar_style(TitleBarStyle::Overlay)
.build()
.unwrap();
}
"quit" => {
app.exit(0);