feat: Multi Accounts (#237)
* wip: new sync * wip: restructure routes * update * feat: improve sync * feat: repost with multi-account * feat: improve sync * feat: publish with multi account * fix: settings screen * feat: add zap for multi accounts
This commit is contained in:
@@ -1,117 +0,0 @@
|
||||
import { cn } from "@/commons";
|
||||
import { User } from "@/components/user";
|
||||
import { LumeWindow } from "@/system";
|
||||
import { CaretDown, Feather, MagnifyingGlass } from "@phosphor-icons/react";
|
||||
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { memo, useCallback } from "react";
|
||||
|
||||
export const Route = createLazyFileRoute("/$account/_app")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const context = Route.useRouteContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-screen h-screen">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={cn(
|
||||
"flex h-10 shrink-0 items-center justify-between",
|
||||
context.platform === "macos" ? "pl-[72px] pr-3" : "pr-[156px] pl-3",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative z-[200] flex-1 flex items-center gap-4"
|
||||
>
|
||||
<Account />
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openSearch()}
|
||||
className="inline-flex items-center justify-center size-7 bg-black/5 dark:bg-white/5 rounded-full hover:bg-blue-500 hover:text-white"
|
||||
>
|
||||
<MagnifyingGlass className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openEditor()}
|
||||
className="inline-flex items-center justify-center h-7 gap-1.5 px-2 text-sm font-medium bg-black/5 dark:bg-white/5 rounded-full w-max hover:bg-blue-500 hover:text-white"
|
||||
>
|
||||
<Feather className="size-4" weight="fill" />
|
||||
New Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="toolbar"
|
||||
data-tauri-drag-region
|
||||
className="relative z-[200] flex-1 flex items-center justify-end gap-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-neutral-100 dark:bg-neutral-900 border-t-[.5px] border-black/20 dark:border-white/20">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Account = memo(function Account() {
|
||||
const params = Route.useParams();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const showContextMenu = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const menuItems = await Promise.all([
|
||||
MenuItem.new({
|
||||
text: "New Post",
|
||||
action: () => LumeWindow.openEditor(),
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Profile",
|
||||
action: () => LumeWindow.openProfile(params.account),
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Settings",
|
||||
action: () => LumeWindow.openSettings(params.account),
|
||||
}),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
MenuItem.new({
|
||||
text: "Copy Public Key",
|
||||
action: async () => await writeText(params.account),
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Logout",
|
||||
action: () => navigate({ to: "/" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
const menu = await Menu.new({
|
||||
items: menuItems,
|
||||
});
|
||||
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
},
|
||||
[params.account],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<User.Provider pubkey={params.account}>
|
||||
<User.Root className="shrink-0 rounded-full">
|
||||
<User.Avatar className="rounded-full size-7" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<CaretDown className="size-3" />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/$account/_app")();
|
||||
@@ -1,40 +0,0 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { NostrAccount } from "@/system";
|
||||
import { Button } from "@getalby/bitcoin-connect-react";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
|
||||
export const Route = createLazyFileRoute("/$account/_settings/bitcoin-connect")(
|
||||
{
|
||||
component: Screen,
|
||||
},
|
||||
);
|
||||
|
||||
function Screen() {
|
||||
const setNwcUri = async (uri: string) => {
|
||||
const res = await commands.setWallet(uri);
|
||||
|
||||
if (res.status === "ok") {
|
||||
await getCurrentWebviewWindow().close();
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center size-full">
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-center">
|
||||
<div>
|
||||
<p className="text-sm text-black/70 dark:text-white/70">
|
||||
Click to the button below to connect with your Bitcoin wallet.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onConnected={(provider) =>
|
||||
setNwcUri(provider.client.nostrWalletConnectUrl)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { appSettings } from "@/commons";
|
||||
import { Spinner } from "@/components";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useStore } from "@tanstack/react-store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useEffect, useState, useTransition } from "react";
|
||||
|
||||
type Theme = "auto" | "light" | "dark";
|
||||
|
||||
export const Route = createLazyFileRoute("/$account/_settings/general")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const [theme, setTheme] = useState<Theme>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const changeTheme = useCallback(async (theme: string) => {
|
||||
if (theme === "auto" || theme === "light" || theme === "dark") {
|
||||
invoke("plugin:theme|set_theme", {
|
||||
theme: theme,
|
||||
}).then(() => setTheme(theme));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateSettings = () => {
|
||||
startTransition(async () => {
|
||||
const newSettings = JSON.stringify(appSettings.state);
|
||||
const res = await commands.setUserSettings(newSettings);
|
||||
|
||||
if (res.status === "error") {
|
||||
await message(res.error, { kind: "error" });
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
invoke("plugin:theme|get_theme").then((data) => setTheme(data as Theme));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="flex flex-col gap-6 px-3 pb-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
General
|
||||
</h2>
|
||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<Setting
|
||||
name="Relay Hint"
|
||||
description="Use the relay hint if necessary."
|
||||
label="use_relay_hint"
|
||||
/>
|
||||
<Setting
|
||||
name="Content Warning"
|
||||
description="Shows a warning for notes that have a content warning."
|
||||
label="content_warning"
|
||||
/>
|
||||
<Setting
|
||||
name="Trusted Only"
|
||||
description="Only shows note's replies from your inner circle."
|
||||
label="trusted_only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
Appearance
|
||||
</h2>
|
||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Appearance</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Change app theme
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<select
|
||||
name="theme"
|
||||
className="w-24 py-1 bg-transparent rounded-lg shadow-none outline-none border-1 border-black/10 dark:border-white/10"
|
||||
defaultValue={theme}
|
||||
onChange={(e) => changeTheme(e.target.value)}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Setting
|
||||
name="Transparent Effect"
|
||||
description="Use native window transparent effect."
|
||||
label="transparent"
|
||||
/>
|
||||
<Setting
|
||||
name="Show Zap Button"
|
||||
description="Shows the Zap button when viewing a note."
|
||||
label="display_zap_button"
|
||||
/>
|
||||
<Setting
|
||||
name="Show Repost Button"
|
||||
description="Shows the Repost button when viewing a note."
|
||||
label="display_repost_button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
Privacy & Performance
|
||||
</h2>
|
||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<Setting
|
||||
name="Proxy"
|
||||
description="Set proxy address."
|
||||
label="proxy"
|
||||
/>
|
||||
<Setting
|
||||
name="Image Resize Service"
|
||||
description="Use weserv/images for resize image on-the-fly."
|
||||
label="image_resize_service"
|
||||
/>
|
||||
<Setting
|
||||
name="Load Remote Media"
|
||||
description="View the remote media directly."
|
||||
label="display_media"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 left-0 w-full h-16 flex items-center justify-end px-3">
|
||||
<div className="absolute left-0 bottom-0 w-full h-11 gradient-mask-t-0 bg-neutral-100 dark:bg-neutral-900" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateSettings()}
|
||||
className="relative z-10 inline-flex items-center justify-center w-20 rounded-md shadow h-8 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium"
|
||||
>
|
||||
{isPending ? <Spinner className="size-4" /> : "Update"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Setting({
|
||||
label,
|
||||
name,
|
||||
description,
|
||||
}: { label: string; name: string; description: string }) {
|
||||
const state = useStore(appSettings, (state) => state[label]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
appSettings.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
[label]: !state[label],
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{name}</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={state}
|
||||
onClick={() => toggle()}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<Switch.Thumb className="block size-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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { appSettings } from "@/commons";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/$account/_settings/general")({
|
||||
beforeLoad: async () => {
|
||||
const res = await commands.getUserSettings();
|
||||
|
||||
if (res.status === "ok") {
|
||||
appSettings.setState((state) => {
|
||||
return { ...state, ...res.data };
|
||||
});
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/$account/_settings/relay")({
|
||||
beforeLoad: async () => {
|
||||
const res = await commands.getRelays();
|
||||
|
||||
if (res.status === "ok") {
|
||||
const relayList = res.data;
|
||||
return { relayList };
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { createLazyFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createLazyFileRoute("/$account/_settings/wallet")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
const { balance } = Route.useRouteContext();
|
||||
|
||||
const disconnect = async () => {
|
||||
const res = await commands.removeWallet();
|
||||
|
||||
if (res.status === "ok") {
|
||||
window.localStorage.removeItem("bc:config");
|
||||
return redirect({ to: "/$account/bitcoin-connect", params: { account } });
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full px-3 pb-3">
|
||||
<div className="flex flex-col w-full gap-3">
|
||||
<div className="flex flex-col w-full px-3 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-center justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Connection</h3>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => disconnect()}
|
||||
className="h-8 w-max px-2.5 text-sm rounded-lg inline-flex items-center justify-center bg-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col w-full px-3 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-center justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Current Balance</h3>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
₿ {balance.bitcoinFormatted}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { getBitcoinDisplayValues } from "@/commons";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/$account/_settings/wallet")({
|
||||
beforeLoad: async ({ params }) => {
|
||||
const query = await commands.loadWallet();
|
||||
|
||||
if (query.status === "ok") {
|
||||
const wallet = Number.parseInt(query.data);
|
||||
const balance = getBitcoinDisplayValues(wallet);
|
||||
|
||||
return { balance };
|
||||
} else {
|
||||
throw redirect({
|
||||
to: "/$account/bitcoin-connect",
|
||||
params: { account: params.account },
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
import { displayNsec } from "@/commons";
|
||||
import { Spinner } from "@/components";
|
||||
import { Check } from "@phosphor-icons/react";
|
||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/$account/backup")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [key, setKey] = useState(null);
|
||||
const [passphase, setPassphase] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirm, setConfirm] = useState({ c1: false, c2: false });
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (key) {
|
||||
if (!confirm.c1 || !confirm.c2) {
|
||||
return await message("You need to confirm before continue", {
|
||||
title: "Backup",
|
||||
kind: "info",
|
||||
});
|
||||
}
|
||||
|
||||
navigate({ to: "/", replace: true });
|
||||
}
|
||||
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
invoke("get_encrypted_key", {
|
||||
npub: account,
|
||||
password: passphase,
|
||||
}).then((encrypted: string) => {
|
||||
// update state
|
||||
setKey(encrypted);
|
||||
setLoading(false);
|
||||
});
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
await message(String(e), {
|
||||
title: "Backup",
|
||||
kind: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copyKey = async () => {
|
||||
try {
|
||||
await writeText(key);
|
||||
setCopied(true);
|
||||
} catch (e) {
|
||||
await message(String(e), {
|
||||
title: "Backup",
|
||||
kind: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||
<div className="flex flex-col text-center">
|
||||
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
||||
<p className="text-neutral-700 dark:text-neutral-300">
|
||||
It's use for login to Lume or other Nostr clients. You will lost
|
||||
access to your account if you lose this key.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col w-full gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="passphase" className="font-medium">
|
||||
Set a passphase to secure your key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="passphase"
|
||||
type="password"
|
||||
value={passphase}
|
||||
onChange={(e) => setPassphase(e.target.value)}
|
||||
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{key ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="nsec" className="font-medium">
|
||||
Copy this key and keep it in safe place
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
name="nsec"
|
||||
type="text"
|
||||
value={key}
|
||||
readOnly
|
||||
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyKey()}
|
||||
className="inline-flex items-center justify-center w-24 rounded-lg h-11 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="font-medium">Before you continue:</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
checked={confirm.c1}
|
||||
onCheckedChange={() =>
|
||||
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
||||
}
|
||||
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
id="confirm1"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
<Check className="size-4" />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
<label
|
||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||
htmlFor="confirm1"
|
||||
>
|
||||
I will make sure keep it safe and not sharing with anyone.
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
checked={confirm.c2}
|
||||
onCheckedChange={() =>
|
||||
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
||||
}
|
||||
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
id="confirm2"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
<Check className="size-4" />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
<label
|
||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||
htmlFor="confirm2"
|
||||
>
|
||||
I understand I cannot recover private key.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center w-full font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : "Continue"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,13 @@ import { appSettings } from "@/commons";
|
||||
import { Spinner } from "@/components";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { OsType } from "@tauri-apps/plugin-os";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface RouterContext {
|
||||
queryClient: QueryClient;
|
||||
platform: OsType;
|
||||
accounts: string[];
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
@@ -33,8 +33,9 @@ function Screen() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen("synchronized", async () => {
|
||||
await queryClient.invalidateQueries();
|
||||
const unlisten = events.negentropyEvent.listen(async (data) => {
|
||||
const queryKey = [data.payload.kind.toLowerCase()];
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
195
src/routes/_layout.lazy.tsx
Normal file
195
src/routes/_layout.lazy.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { cn } from "@/commons";
|
||||
import { PublishIcon } from "@/components";
|
||||
import { User } from "@/components/user";
|
||||
import { LumeWindow } from "@/system";
|
||||
import { MagnifyingGlass, Plus } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, Outlet, createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
|
||||
export const Route = createLazyFileRoute("/_layout")({
|
||||
component: Layout,
|
||||
});
|
||||
|
||||
function Layout() {
|
||||
return (
|
||||
<div className="flex flex-col w-screen h-screen">
|
||||
<Topbar />
|
||||
<div className="flex-1 bg-neutral-100 dark:bg-neutral-900 border-t-[.5px] border-black/20 dark:border-white/20">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Topbar() {
|
||||
const { platform, accounts } = Route.useRouteContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={cn(
|
||||
"flex h-10 shrink-0 items-center justify-between",
|
||||
platform === "macos" ? "pl-[72px] pr-3" : "pr-[156px] pl-3",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative z-[200] h-10 flex-1 flex items-center gap-2"
|
||||
>
|
||||
{accounts?.map((account) => (
|
||||
<Account key={account} pubkey={account} />
|
||||
))}
|
||||
<Link
|
||||
to="/new"
|
||||
className="inline-flex items-center justify-center size-7 bg-black/5 dark:bg-white/5 rounded-full hover:bg-blue-500 hover:text-white"
|
||||
>
|
||||
<Plus className="size-4" weight="bold" />
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative z-[200] flex-1 flex items-center justify-end gap-4"
|
||||
>
|
||||
{accounts?.length ? (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openEditor()}
|
||||
className="inline-flex items-center justify-center h-7 gap-1 px-2 text-sm font-medium bg-black/5 dark:bg-white/5 rounded-full w-max hover:bg-blue-500 hover:text-white"
|
||||
>
|
||||
<PublishIcon className="size-4" />
|
||||
New Post
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openSearch()}
|
||||
className="inline-flex items-center justify-center size-7 bg-black/5 dark:bg-white/5 rounded-full hover:bg-blue-500 hover:text-white"
|
||||
>
|
||||
<MagnifyingGlass className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div id="toolbar" className="inline-flex items-center gap-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NegentropyBadge = memo(function NegentropyBadge() {
|
||||
const [process, setProcess] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen("negentropy", async (data) => {
|
||||
if (data.payload === "Ok") {
|
||||
setProcess(null);
|
||||
} else {
|
||||
setProcess(data.payload);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!process) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-7 w-max px-3 inline-flex items-center justify-center text-[9px] font-medium rounded-full bg-black/5 dark:bg-white/5">
|
||||
{process ? (
|
||||
<span>
|
||||
{process.message}
|
||||
{process.total_event > 0 ? ` / ${process.total_event}` : null}
|
||||
</span>
|
||||
) : (
|
||||
"Syncing"
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Account({ pubkey }: { pubkey: string }) {
|
||||
const navigate = Route.useNavigate();
|
||||
const context = Route.useRouteContext();
|
||||
|
||||
const { data: isActive } = useQuery({
|
||||
queryKey: ["signer", pubkey],
|
||||
queryFn: async () => {
|
||||
const res = await commands.hasSigner(pubkey);
|
||||
|
||||
if (res.status === "ok") {
|
||||
return res.data;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const showContextMenu = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const items = await Promise.all([
|
||||
MenuItem.new({
|
||||
text: "View Profile",
|
||||
action: () => LumeWindow.openProfile(pubkey),
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Copy Public Key",
|
||||
action: async () => await writeText(pubkey),
|
||||
}),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
MenuItem.new({
|
||||
text: "Settings",
|
||||
action: () => LumeWindow.openSettings(pubkey),
|
||||
}),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
MenuItem.new({
|
||||
text: "Logout",
|
||||
action: async () => {
|
||||
const res = await commands.deleteAccount(pubkey);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const newAccounts = context.accounts.filter(
|
||||
(account) => account !== pubkey,
|
||||
);
|
||||
|
||||
if (newAccounts.length < 1) {
|
||||
navigate({ to: "/", replace: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const menu = await Menu.new({ items });
|
||||
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
},
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className="h-10 relative"
|
||||
>
|
||||
<User.Provider pubkey={pubkey}>
|
||||
<User.Root className="shrink-0 rounded-full">
|
||||
<User.Avatar className="rounded-full size-7" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
{isActive ? (
|
||||
<div className="h-px w-full absolute bottom-0 left-0 bg-green-500 rounded-full" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
14
src/routes/_layout.tsx
Normal file
14
src/routes/_layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_layout")({
|
||||
beforeLoad: async () => {
|
||||
const accounts = await commands.getAccounts();
|
||||
|
||||
if (!accounts.length) {
|
||||
throw redirect({ to: "/new", replace: true });
|
||||
}
|
||||
|
||||
return { accounts };
|
||||
},
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
import { appColumns } from "@/commons";
|
||||
import { Spinner } from "@/components";
|
||||
import { Column } from "@/components/column";
|
||||
import { Column, Spinner } from "@/components";
|
||||
import { LumeWindow } from "@/system";
|
||||
import type { ColumnEvent, LumeColumn } from "@/types";
|
||||
import { ArrowLeft, ArrowRight, Plus, StackPlus } from "@phosphor-icons/react";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useStore } from "@tanstack/react-store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||
import { resolveResource } from "@tauri-apps/api/path";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
@@ -23,12 +21,11 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
export const Route = createLazyFileRoute("/$account/_app/home")({
|
||||
export const Route = createLazyFileRoute("/_layout/")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const params = Route.useParams();
|
||||
const columns = useStore(appColumns, (state) => state);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
@@ -158,9 +155,7 @@ function Screen() {
|
||||
}
|
||||
|
||||
if (!columns.length) {
|
||||
const prevColumns = window.localStorage.getItem(
|
||||
`${params.account}_columns`,
|
||||
);
|
||||
const prevColumns = window.localStorage.getItem("columns");
|
||||
|
||||
if (!prevColumns) {
|
||||
getSystemColumns();
|
||||
@@ -169,10 +164,7 @@ function Screen() {
|
||||
appColumns.setState(() => parsed);
|
||||
}
|
||||
} else {
|
||||
window.localStorage.setItem(
|
||||
`${params.account}_columns`,
|
||||
JSON.stringify(columns),
|
||||
);
|
||||
window.localStorage.setItem("columns", JSON.stringify(columns));
|
||||
}
|
||||
}, [columns.length]);
|
||||
|
||||
@@ -193,7 +185,7 @@ function Screen() {
|
||||
<div className="size-full flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openColumnsGallery()}
|
||||
onClick={() => LumeWindow.openLaunchpad()}
|
||||
className="inline-flex items-center justify-center gap-1 rounded-full text-sm font-medium h-8 w-max pl-2 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
@@ -204,7 +196,13 @@ function Screen() {
|
||||
</div>
|
||||
</div>
|
||||
<Toolbar>
|
||||
<ManageButton />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openLaunchpad()}
|
||||
className="inline-flex items-center justify-center rounded-full size-7 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<StackPlus className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollPrev()}
|
||||
@@ -224,44 +222,6 @@ function Screen() {
|
||||
);
|
||||
}
|
||||
|
||||
function ManageButton() {
|
||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const menuItems = await Promise.all([
|
||||
MenuItem.new({
|
||||
text: "Open Launchpad",
|
||||
action: () => LumeWindow.openColumnsGallery(),
|
||||
}),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
MenuItem.new({
|
||||
text: "Open Newsfeed",
|
||||
action: () => LumeWindow.openLocalFeeds(),
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Open Notification",
|
||||
action: () => LumeWindow.openNotification(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const menu = await Menu.new({
|
||||
items: menuItems,
|
||||
});
|
||||
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className="inline-flex items-center justify-center rounded-full size-7 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<StackPlus className="size-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({ children }: { children: ReactNode[] }) {
|
||||
const [domReady, setDomReady] = useState(false);
|
||||
|
||||
3
src/routes/_layout/index.tsx
Normal file
3
src/routes/_layout/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_layout/')()
|
||||
@@ -44,20 +44,22 @@ function Screen() {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="size-full flex items-center justify-center"
|
||||
className="bg-white/50 dark:bg-black/50 size-full flex items-center justify-center"
|
||||
>
|
||||
<div className="w-[320px] flex flex-col gap-8">
|
||||
<div className="w-[340px] flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h1 className="leading-tight text-xl font-semibold">Nostr Connect</h1>
|
||||
<h1 className="leading-tight text-xl font-semibold">
|
||||
Continue with Nostr Connect
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Frame
|
||||
className="flex flex-col gap-1 p-3 rounded-xl overflow-hidden"
|
||||
className="flex flex-col gap-3 p-4 rounded-xl overflow-hidden"
|
||||
shadow
|
||||
>
|
||||
<label
|
||||
htmlFor="uri"
|
||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||
className="text-sm font-semibold text-neutral-800 dark:text-neutral-200"
|
||||
>
|
||||
Connection String
|
||||
</label>
|
||||
@@ -68,7 +70,7 @@ function Screen() {
|
||||
placeholder="bunker://..."
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
|
||||
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-700 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -46,7 +46,6 @@ function Screen() {
|
||||
navigate({ to: "/", replace: true });
|
||||
} else {
|
||||
await message(res.error, {
|
||||
title: "Import Private Ket",
|
||||
kind: "error",
|
||||
});
|
||||
return;
|
||||
@@ -57,23 +56,23 @@ function Screen() {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="size-full flex items-center justify-center"
|
||||
className="bg-white/50 dark:bg-black/50 size-full flex items-center justify-center"
|
||||
>
|
||||
<div className="w-[320px] flex flex-col gap-8">
|
||||
<div className="w-[340px] flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h1 className="leading-tight text-xl font-semibold">
|
||||
Import Private Key
|
||||
Continue with Secret Key
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Frame
|
||||
className="flex flex-col gap-3 p-3 rounded-xl overflow-hidden"
|
||||
className="flex flex-col gap-3 p-4 rounded-xl overflow-hidden"
|
||||
shadow
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<label
|
||||
htmlFor="key"
|
||||
className="text-sm font-medium text-neutral-800 dark:text-neutral-200"
|
||||
className="text-sm font-semibold text-neutral-800 dark:text-neutral-200"
|
||||
>
|
||||
Private Key
|
||||
</label>
|
||||
@@ -84,7 +83,7 @@ function Screen() {
|
||||
placeholder="nsec or ncryptsec..."
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
|
||||
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-700 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -110,7 +109,7 @@ function Screen() {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none"
|
||||
className="px-3 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-700 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { upload } from "@/commons";
|
||||
import { Frame, GoBack, Spinner } from "@/components";
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
export const Route = createLazyFileRoute("/auth/new")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [picture, setPicture] = useState<string>("");
|
||||
const [name, setName] = useState("");
|
||||
const [about, setAbout] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const uploadAvatar = async () => {
|
||||
const file = await upload();
|
||||
|
||||
if (file) {
|
||||
setPicture(file);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
startTransition(async () => {
|
||||
if (!name.length) {
|
||||
await message("Please add your name", {
|
||||
title: "New Identity",
|
||||
kind: "info",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password.length) {
|
||||
await message("You must set password to secure your account", {
|
||||
title: "New Identity",
|
||||
kind: "info",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await commands.createAccount(name, picture, about, password);
|
||||
|
||||
if (res.status === "ok") {
|
||||
navigate({
|
||||
to: "/",
|
||||
replace: true,
|
||||
});
|
||||
} else {
|
||||
await message(res.error, {
|
||||
title: "New Identity",
|
||||
kind: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="size-full flex items-center justify-center"
|
||||
>
|
||||
<div className="w-[320px] flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h1 className="leading-tight text-xl font-semibold">New Identity</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Frame
|
||||
className="flex flex-col gap-3 p-3 rounded-xl overflow-hidden"
|
||||
shadow
|
||||
>
|
||||
<div className="self-center relative rounded-full size-20 bg-neutral-100 dark:bg-white/10 my-3">
|
||||
{picture.length ? (
|
||||
<img
|
||||
src={picture}
|
||||
alt="avatar"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadAvatar()}
|
||||
className="absolute inset-0 z-20 flex items-center justify-center w-full h-full rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="text-sm font-medium text-neutral-800 dark:text-neutral-200"
|
||||
>
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Alice"
|
||||
spellCheck={false}
|
||||
className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="about"
|
||||
className="text-sm font-medium text-neutral-800 dark:text-neutral-200"
|
||||
>
|
||||
About
|
||||
</label>
|
||||
<textarea
|
||||
name="about"
|
||||
value={about}
|
||||
onChange={(e) => setAbout(e.target.value)}
|
||||
placeholder="e.g. Artist, anime-lover, and k-pop fan"
|
||||
spellCheck={false}
|
||||
className="px-3 py-1.5 rounded-lg min-h-16 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-px w-full mt-2 bg-neutral-100 dark:bg-neutral-900" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium text-neutral-800 dark:text-neutral-200"
|
||||
>
|
||||
Set password to secure your account *
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="px-3 rounded-lg h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:ring-0 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:text-neutral-200"
|
||||
/>
|
||||
</div>
|
||||
</Frame>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center justify-center w-full h-9 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? <Spinner /> : "Continue"}
|
||||
</button>
|
||||
<GoBack className="mt-2 w-full text-sm text-neutral-600 dark:text-neutral-400 inline-flex items-center justify-center">
|
||||
Go back to previous screen
|
||||
</GoBack>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/routes/auth/watch.lazy.tsx
Normal file
106
src/routes/auth/watch.lazy.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { Frame, GoBack } from "@/components";
|
||||
import { Spinner } from "@/components/spinner";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { readText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
export const Route = createLazyFileRoute("/auth/watch")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const [key, setKey] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const pasteFromClipboard = async () => {
|
||||
const val = await readText();
|
||||
setKey(val);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
startTransition(async () => {
|
||||
if (!key.startsWith("npub") && !key.startsWith("nprofile")) {
|
||||
await message(
|
||||
"You need to enter a valid public key starts with npub or nprofile",
|
||||
{ title: "Login", kind: "info" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await commands.watchAccount(key);
|
||||
|
||||
if (res.status === "ok") {
|
||||
navigate({ to: "/", replace: true });
|
||||
} else {
|
||||
await message(res.error, {
|
||||
kind: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="bg-white/50 dark:bg-black/50 size-full flex items-center justify-center"
|
||||
>
|
||||
<div className="w-[340px] flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h1 className="leading-tight text-xl font-semibold">
|
||||
Continue with Public Key (Watch Mode)
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<Frame
|
||||
className="flex flex-col gap-3 p-4 rounded-xl overflow-hidden"
|
||||
shadow
|
||||
>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<label
|
||||
htmlFor="key"
|
||||
className="text-sm font-semibold text-neutral-800 dark:text-neutral-200"
|
||||
>
|
||||
Public Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="key"
|
||||
type="password"
|
||||
placeholder="npub or nprofile..."
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
className="pl-3 pr-12 rounded-lg w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-700 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pasteFromClipboard()}
|
||||
className="absolute top-1/2 right-2 transform -translate-y-1/2 text-xs font-semibold text-blue-500 dark:text-blue-300"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Frame>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center justify-center w-full h-9 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? <Spinner /> : "Continue"}
|
||||
</button>
|
||||
<GoBack className="mt-2 w-full text-sm text-neutral-600 dark:text-neutral-400 inline-flex items-center justify-center">
|
||||
Go back to previous screen
|
||||
</GoBack>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { appSettings } from "@/commons";
|
||||
import type { ColumnRouteSearch } from "@/types";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export interface RouteSearch {
|
||||
label?: string;
|
||||
name?: string;
|
||||
redirect?: string;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/columns/_layout")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
validateSearch: (search: Record<string, string>): RouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export const Route = createFileRoute("/columns/_layout/global")({
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { label, account } = Route.useSearch();
|
||||
const { label } = Route.useSearch();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -24,7 +24,7 @@ export function Screen() {
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [label, account],
|
||||
queryKey: ["events", "global", label],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const until = pageParam > 0 ? pageParam.toString() : undefined;
|
||||
|
||||
@@ -26,7 +26,7 @@ export function Screen() {
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["groups", params.id],
|
||||
queryKey: ["events", "groups", params.id],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const until = pageParam > 0 ? pageParam.toString() : undefined;
|
||||
|
||||
@@ -26,7 +26,7 @@ export function Screen() {
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["hashtags", params.id],
|
||||
queryKey: ["events", "hashtags", params.id],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const tags = hashtags.map((tag) => tag.toLowerCase().replace("#", ""));
|
||||
|
||||
@@ -26,6 +26,7 @@ function Screen() {
|
||||
<ScrollArea.Viewport className="relative h-full px-3 pb-3">
|
||||
<Groups />
|
||||
<Interests />
|
||||
<Accounts />
|
||||
<Core />
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
@@ -39,66 +40,9 @@ function Screen() {
|
||||
);
|
||||
}
|
||||
|
||||
function Core() {
|
||||
const { isLoading, data } = useQuery({
|
||||
queryKey: ["core"],
|
||||
queryFn: async () => {
|
||||
const systemPath = "resources/columns.json";
|
||||
const resourcePath = await resolveResource(systemPath);
|
||||
const resourceFile = await readTextFile(resourcePath);
|
||||
|
||||
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
|
||||
const columns = systemColumns.filter((col) => !col.default);
|
||||
|
||||
return columns;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="font-semibold">Core</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{isLoading ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<Spinner className="size-4" />
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
data.map((column) => (
|
||||
<div
|
||||
key={column.label}
|
||||
className="group flex px-4 items-center justify-between h-16 rounded-xl bg-white dark:bg-black border-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
>
|
||||
<div className="text-sm">
|
||||
<div className="mb-px leading-tight font-semibold">
|
||||
{column.name}
|
||||
</div>
|
||||
<div className="leading-tight text-neutral-500 dark:text-neutral-400">
|
||||
{column.description}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openColumn(column)}
|
||||
className="text-xs uppercase font-semibold w-16 h-7 hidden group-hover:inline-flex items-center justify-center rounded-full bg-neutral-200 hover:bg-blue-500 hover:text-white dark:bg-black/10"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Groups() {
|
||||
const { account } = Route.useSearch();
|
||||
const { isLoading, data, refetch, isRefetching } = useQuery({
|
||||
queryKey: ["groups", account],
|
||||
queryKey: ["others", "groups"],
|
||||
queryFn: async () => {
|
||||
const res = await commands.getAllGroups();
|
||||
|
||||
@@ -125,23 +69,32 @@ function Groups() {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group flex flex-col rounded-xl overflow-hidden bg-neutral-200/50 dark:bg-neutral-800/50 border-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
className="group flex flex-col rounded-xl overflow-hidden bg-white dark:bg-neutral-800/50 shadow-lg shadow-primary dark:ring-1 dark:ring-neutral-800"
|
||||
>
|
||||
<div className="p-3 h-16 flex flex-wrap items-center justify-center gap-2 overflow-y-auto">
|
||||
{item.tags
|
||||
.filter((tag) => tag[0] === "p")
|
||||
.map((tag) => (
|
||||
<div key={tag[1]}>
|
||||
<User.Provider pubkey={tag[1]}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-8 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
))}
|
||||
<div className="px-2 pt-2">
|
||||
<div className="p-3 h-16 bg-neutral-100 rounded-lg flex flex-wrap items-center justify-center gap-2 overflow-y-auto">
|
||||
{item.tags
|
||||
.filter((tag) => tag[0] === "p")
|
||||
.map((tag) => (
|
||||
<div key={tag[1]}>
|
||||
<User.Provider pubkey={tag[1]}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-8 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{name}</div>
|
||||
<div className="p-2 flex items-center justify-between">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Provider pubkey={item.pubkey}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-7 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<h5 className="text-xs font-medium">{name}</h5>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -181,9 +134,7 @@ function Groups() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
LumeWindow.openPopup("New group", `/set-group?account=${account}`)
|
||||
}
|
||||
onClick={() => LumeWindow.openPopup("/set-group", "New group")}
|
||||
className="h-7 w-max px-2 inline-flex items-center justify-center gap-1 text-sm font-medium rounded-full bg-neutral-300 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
|
||||
>
|
||||
<Plus className="size-3" weight="bold" />
|
||||
@@ -210,9 +161,8 @@ function Groups() {
|
||||
}
|
||||
|
||||
function Interests() {
|
||||
const { account } = Route.useSearch();
|
||||
const { isLoading, data, refetch, isRefetching } = useQuery({
|
||||
queryKey: ["interests", account],
|
||||
queryKey: ["others", "interests"],
|
||||
queryFn: async () => {
|
||||
const res = await commands.getAllInterests();
|
||||
|
||||
@@ -240,19 +190,28 @@ function Interests() {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group flex flex-col rounded-xl overflow-hidden bg-neutral-200/50 dark:bg-neutral-800/50 border-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
className="group flex flex-col rounded-xl overflow-hidden bg-white dark:bg-neutral-800/50 shadow-lg shadow-primary dark:ring-1 dark:ring-neutral-800"
|
||||
>
|
||||
<div className="p-3 h-16 flex flex-wrap items-center justify-center gap-2 overflow-y-auto">
|
||||
{item.tags
|
||||
.filter((tag) => tag[0] === "t")
|
||||
.map((tag) => (
|
||||
<div key={tag[1]} className="text-sm font-medium">
|
||||
{tag[1]}
|
||||
</div>
|
||||
))}
|
||||
<div className="px-2 pt-2">
|
||||
<div className="p-3 h-16 bg-neutral-100 rounded-lg flex flex-wrap items-center justify-center gap-4 overflow-y-auto">
|
||||
{item.tags
|
||||
.filter((tag) => tag[0] === "t")
|
||||
.map((tag) => (
|
||||
<div key={tag[1]} className="text-sm font-medium">
|
||||
{tag[1]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{name}</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Provider pubkey={item.pubkey}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-7 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<h5 className="text-xs font-medium">{name}</h5>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -293,10 +252,7 @@ function Interests() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
LumeWindow.openPopup(
|
||||
"New interest",
|
||||
`/set-interest?account=${account}`,
|
||||
)
|
||||
LumeWindow.openPopup("/set-interest", "New interest")
|
||||
}
|
||||
className="h-7 w-max px-2 inline-flex items-center justify-center gap-1 text-sm font-medium rounded-full bg-neutral-300 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
|
||||
>
|
||||
@@ -322,3 +278,134 @@ function Interests() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Accounts() {
|
||||
const { isLoading, data: accounts } = useQuery({
|
||||
queryKey: ["accounts"],
|
||||
queryFn: async () => {
|
||||
const res = await commands.getAccounts();
|
||||
return res;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mb-12 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="font-semibold">Accounts</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{isLoading ? (
|
||||
<div className="inline-flex items-center gap-1.5 text-sm">
|
||||
<Spinner className="size-4" />
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<div
|
||||
key={account}
|
||||
className="group flex flex-col rounded-xl overflow-hidden bg-white dark:bg-neutral-800/50 shadow-lg shadow-primary dark:ring-1 dark:ring-neutral-800"
|
||||
>
|
||||
<div className="px-2 pt-2">
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-7 rounded-full" />
|
||||
<User.Name className="text-xs font-medium" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="px-3 flex items-center justify-between h-11 rounded-lg bg-neutral-100 dark:bg-neutral-800">
|
||||
<div className="text-sm font-medium">Newsfeed</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openNewsfeed(account)}
|
||||
className="h-6 w-16 inline-flex items-center justify-center gap-1 text-xs font-semibold rounded-full bg-neutral-200 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3 flex items-center justify-between h-11 rounded-lg bg-neutral-100 dark:bg-neutral-800">
|
||||
<div className="text-sm font-medium">Stories</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openStory(account)}
|
||||
className="h-6 w-16 inline-flex items-center justify-center gap-1 text-xs font-semibold rounded-full bg-neutral-200 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3 flex items-center justify-between h-11 rounded-lg bg-neutral-100 dark:bg-neutral-800">
|
||||
<div className="text-sm font-medium">Notification</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openNotification(account)}
|
||||
className="h-6 w-16 inline-flex items-center justify-center gap-1 text-xs font-semibold rounded-full bg-neutral-200 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Core() {
|
||||
const { isLoading, data } = useQuery({
|
||||
queryKey: ["other-columns"],
|
||||
queryFn: async () => {
|
||||
const systemPath = "resources/columns.json";
|
||||
const resourcePath = await resolveResource(systemPath);
|
||||
const resourceFile = await readTextFile(resourcePath);
|
||||
|
||||
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
|
||||
const columns = systemColumns.filter((col) => !col.default);
|
||||
|
||||
return columns;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="font-semibold">Others</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{isLoading ? (
|
||||
<div className="inline-flex items-center gap-1.5 text-sm">
|
||||
<Spinner className="size-4" />
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
data.map((column) => (
|
||||
<div
|
||||
key={column.label}
|
||||
className="group flex px-4 items-center justify-between h-16 rounded-xl bg-white dark:bg-black border-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
>
|
||||
<div className="text-sm">
|
||||
<div className="mb-px leading-tight font-semibold">
|
||||
{column.name}
|
||||
</div>
|
||||
<div className="leading-tight text-neutral-500 dark:text-neutral-400">
|
||||
{column.description}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openColumn(column)}
|
||||
className="text-xs font-semibold w-16 h-7 hidden group-hover:inline-flex items-center justify-center rounded-full bg-neutral-200 hover:bg-blue-500 hover:text-white dark:bg-black/10"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,23 @@
|
||||
import { events, commands } from "@/commands.gen";
|
||||
import { toLumeEvents } from "@/commons";
|
||||
import { RepostNote, Spinner, TextNote } from "@/components";
|
||||
import { LumeEvent } from "@/system";
|
||||
import { Kind, type Meta } from "@/types";
|
||||
import { ArrowDown, ArrowUp } from "@phosphor-icons/react";
|
||||
import type { LumeEvent } from "@/system";
|
||||
import { Kind } from "@/types";
|
||||
import { ArrowDown } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { type InfiniteData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
type Payload = {
|
||||
raw: string;
|
||||
parsed: Meta;
|
||||
};
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/newsfeed")({
|
||||
export const Route = createLazyFileRoute("/columns/_layout/newsfeed/$id")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const contacts = Route.useLoaderData();
|
||||
const { label, account } = Route.useSearch();
|
||||
const search = Route.useSearch();
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -38,7 +26,7 @@ export function Screen() {
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [label, account],
|
||||
queryKey: ["events", "newsfeed", search.label],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const until = pageParam > 0 ? pageParam.toString() : undefined;
|
||||
@@ -59,7 +47,10 @@ export function Screen() {
|
||||
|
||||
const renderItem = useCallback(
|
||||
(event: LumeEvent) => {
|
||||
if (!event) return;
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return (
|
||||
@@ -82,6 +73,28 @@ export function Screen() {
|
||||
[data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
events.subscription
|
||||
.emit({
|
||||
label: search.label,
|
||||
kind: "Subscribe",
|
||||
event_id: undefined,
|
||||
contacts,
|
||||
})
|
||||
.then(() => console.log("Subscribe: ", search.label));
|
||||
|
||||
return () => {
|
||||
events.subscription
|
||||
.emit({
|
||||
label: search.label,
|
||||
kind: "Unsubscribe",
|
||||
event_id: undefined,
|
||||
contacts,
|
||||
})
|
||||
.then(() => console.log("Unsubscribe: ", search.label));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
@@ -92,7 +105,6 @@ export function Screen() {
|
||||
ref={ref}
|
||||
className="relative h-full bg-white dark:bg-black rounded-t-xl shadow shadow-neutral-300/50 dark:shadow-none border-[.5px] border-neutral-300 dark:border-neutral-700"
|
||||
>
|
||||
<Listener />
|
||||
<Virtualizer scrollRef={ref}>
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
|
||||
@@ -145,85 +157,3 @@ export function Screen() {
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const Listener = memo(function Listerner() {
|
||||
const { queryClient } = Route.useRouteContext();
|
||||
const { label, account } = Route.useSearch();
|
||||
|
||||
const [lumeEvents, setLumeEvents] = useState<LumeEvent[]>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const queryStatus = queryClient.getQueryState([label, account]);
|
||||
|
||||
const pushNewEvents = () => {
|
||||
startTransition(() => {
|
||||
queryClient.setQueryData(
|
||||
[label, account],
|
||||
(oldData: InfiniteData<LumeEvent[], number> | undefined) => {
|
||||
if (oldData) {
|
||||
const firstPage = oldData.pages[0];
|
||||
const newPage = [...lumeEvents, ...firstPage];
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
pages: [newPage, ...oldData.pages.slice(1)],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Reset array
|
||||
setLumeEvents([]);
|
||||
|
||||
return;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
events.subscription
|
||||
.emit({ label, kind: "Subscribe", event_id: undefined })
|
||||
.then(() => console.log("Subscribe: ", label));
|
||||
|
||||
return () => {
|
||||
events.subscription
|
||||
.emit({
|
||||
label,
|
||||
kind: "Unsubscribe",
|
||||
event_id: undefined,
|
||||
})
|
||||
.then(() => console.log("Unsubscribe: ", label));
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = getCurrentWindow().listen<Payload>("event", (data) => {
|
||||
const event = LumeEvent.from(data.payload.raw, data.payload.parsed);
|
||||
setLumeEvents((prev) => [event, ...prev]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (lumeEvents.length && queryStatus.fetchStatus !== "fetching") {
|
||||
return (
|
||||
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pushNewEvents()}
|
||||
className="w-max h-8 pl-2 pr-3 inline-flex items-center justify-center gap-1.5 rounded-full shadow-lg text-sm font-medium text-white bg-black dark:text-black dark:bg-white"
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<ArrowUp className="size-4" />
|
||||
)}
|
||||
{lumeEvents.length} new notes
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/columns/_layout/stories")({
|
||||
loader: async () => {
|
||||
const res = await commands.getContactList();
|
||||
export const Route = createFileRoute("/columns/_layout/newsfeed/$id")({
|
||||
loader: async ({ params }) => {
|
||||
const res = await commands.getContactList(params.id);
|
||||
|
||||
if (res.status === "ok") {
|
||||
return res.data;
|
||||
@@ -13,17 +13,17 @@ import { nip19 } from "nostr-tools";
|
||||
import { type ReactNode, useEffect, useMemo, useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/notification")({
|
||||
export const Route = createLazyFileRoute("/columns/_layout/notification/$id")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useSearch();
|
||||
const { id } = Route.useParams();
|
||||
const { queryClient } = Route.useRouteContext();
|
||||
const { isLoading, data } = useQuery({
|
||||
queryKey: ["notification", account],
|
||||
queryKey: ["notification", id],
|
||||
queryFn: async () => {
|
||||
const res = await commands.getNotifications();
|
||||
const res = await commands.getNotifications(id);
|
||||
|
||||
if (res.status === "error") {
|
||||
throw new Error(res.error);
|
||||
@@ -37,7 +37,7 @@ function Screen() {
|
||||
select: (events) => {
|
||||
const zaps = new Map<string, LumeEvent[]>();
|
||||
const reactions = new Map<string, LumeEvent[]>();
|
||||
const hex = nip19.decode(account).data;
|
||||
const hex = nip19.decode(id).data;
|
||||
|
||||
const texts = events.filter(
|
||||
(ev) => ev.kind === Kind.Text && ev.pubkey !== hex,
|
||||
@@ -80,7 +80,7 @@ function Screen() {
|
||||
const unlisten = getCurrentWindow().listen("event", async (data) => {
|
||||
const event: LumeEvent = JSON.parse(data.payload as string);
|
||||
await queryClient.setQueryData(
|
||||
["notification", account],
|
||||
["notification", id],
|
||||
(data: LumeEvent[]) => [event, ...data],
|
||||
);
|
||||
});
|
||||
@@ -88,7 +88,7 @@ function Screen() {
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, [account]);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -130,8 +130,8 @@ function Screen() {
|
||||
className="min-h-0 flex-1 overflow-x-hidden"
|
||||
>
|
||||
<Tab value="replies">
|
||||
{data.texts.map((event, index) => (
|
||||
<TextNote key={event.id + index} event={event} />
|
||||
{data.texts.map((event) => (
|
||||
<TextNote key={event.id} event={event} />
|
||||
))}
|
||||
</Tab>
|
||||
<Tab value="reactions">
|
||||
@@ -14,7 +14,7 @@ import { type ReactNode, memo, useMemo, useRef } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/stories")({
|
||||
export const Route = createLazyFileRoute("/columns/_layout/stories/$id")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ function StoryItem({ contact }: { contact: string }) {
|
||||
error,
|
||||
data: events,
|
||||
} = useQuery({
|
||||
queryKey: ["stories", contact],
|
||||
queryKey: ["events", "story", contact],
|
||||
queryFn: async () => {
|
||||
const res = await commands.getAllEventsByAuthor(contact, 10);
|
||||
|
||||
@@ -113,7 +113,7 @@ function StoryItem({ contact }: { contact: string }) {
|
||||
</div>
|
||||
) : !events.length ? (
|
||||
<div className="w-full h-[calc(300px-48px)] flex items-center justify-center text-sm">
|
||||
This user didn't have any new notes.
|
||||
This user didn't have any new notes in the last few days.
|
||||
</div>
|
||||
) : (
|
||||
events.map((event) => <StoryEvent key={event.id} event={event} />)
|
||||
@@ -1,9 +1,9 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/columns/_layout/newsfeed")({
|
||||
loader: async () => {
|
||||
const res = await commands.getContactList();
|
||||
export const Route = createFileRoute("/columns/_layout/stories/$id")({
|
||||
loader: async ({ params }) => {
|
||||
const res = await commands.getContactList(params.id);
|
||||
|
||||
if (res.status === "ok") {
|
||||
return res.data;
|
||||
@@ -13,7 +13,7 @@ export const Route = createLazyFileRoute("/columns/_layout/trending")({
|
||||
|
||||
function Screen() {
|
||||
const { isLoading, data } = useQuery({
|
||||
queryKey: ["trending-notes"],
|
||||
queryKey: ["trending"],
|
||||
queryFn: async ({ signal }) => {
|
||||
const res = await fetch("https://api.nostr.band/v0/trending/notes", {
|
||||
signal,
|
||||
|
||||
@@ -16,7 +16,7 @@ export const Route = createLazyFileRoute("/columns/_layout/users/$id")({
|
||||
function Screen() {
|
||||
const { id } = Route.useParams();
|
||||
const { isLoading, data: events } = useQuery({
|
||||
queryKey: ["stories", id],
|
||||
queryKey: ["events", "story", id],
|
||||
queryFn: async () => {
|
||||
const res = await commands.getAllEventsByAuthor(id, 100);
|
||||
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { appSettings, displayNpub } from "@/commons";
|
||||
import { Frame, Spinner, User } from "@/components";
|
||||
import { ArrowRight, DotsThree, GearSix, Plus } from "@phosphor-icons/react";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
|
||||
export const Route = createLazyFileRoute("/")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const context = Route.useRouteContext();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const currentDate = useMemo(
|
||||
() =>
|
||||
new Date().toLocaleString("default", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [value, setValue] = useState("");
|
||||
const [autoLogin, setAutoLogin] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const showContextMenu = useCallback(
|
||||
async (e: React.MouseEvent, account: string) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const menuItems = await Promise.all([
|
||||
MenuItem.new({
|
||||
text: "Reset password",
|
||||
enabled: !account.includes("_nostrconnect"),
|
||||
// @ts-ignore, this is tanstack router bug
|
||||
action: () => navigate({ to: "/reset", search: { account } }),
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Delete account",
|
||||
action: async () => await deleteAccount(account),
|
||||
}),
|
||||
]);
|
||||
|
||||
const menu = await Menu.new({
|
||||
items: menuItems,
|
||||
});
|
||||
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteAccount = async (account: string) => {
|
||||
const res = await commands.deleteAccount(account);
|
||||
|
||||
if (res.status === "ok") {
|
||||
setAccounts((prev) => prev.filter((item) => item !== account));
|
||||
}
|
||||
};
|
||||
|
||||
const selectAccount = (account: string) => {
|
||||
setValue(account);
|
||||
|
||||
if (account.includes("_nostrconnect")) {
|
||||
setAutoLogin(true);
|
||||
}
|
||||
};
|
||||
|
||||
const loginWith = () => {
|
||||
startTransition(async () => {
|
||||
const res = await commands.login(value, password);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const settings = await commands.getUserSettings();
|
||||
|
||||
if (settings.status === "ok") {
|
||||
appSettings.setState(() => settings.data);
|
||||
}
|
||||
|
||||
const status = await commands.isAccountSync(res.data);
|
||||
|
||||
if (status) {
|
||||
navigate({
|
||||
to: "/$account/home",
|
||||
// @ts-ignore, this is tanstack router bug
|
||||
params: { account: res.data },
|
||||
replace: true,
|
||||
});
|
||||
} else {
|
||||
navigate({
|
||||
to: "/loading",
|
||||
// @ts-ignore, this is tanstack router bug
|
||||
search: { account: res.data },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await message(res.error, { title: "Login", kind: "error" });
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLogin) {
|
||||
loginWith();
|
||||
}
|
||||
}, [autoLogin, value]);
|
||||
|
||||
useEffect(() => {
|
||||
setAccounts(context.accounts);
|
||||
}, [context.accounts]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative size-full flex items-center justify-center"
|
||||
>
|
||||
<div className="w-[320px] flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h3 className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
{currentDate}
|
||||
</h3>
|
||||
<h1 className="leading-tight text-xl font-semibold">Welcome back!</h1>
|
||||
</div>
|
||||
<Frame
|
||||
className="flex flex-col w-full divide-y divide-neutral-100 dark:divide-white/5 rounded-xl overflow-hidden"
|
||||
shadow
|
||||
>
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account}
|
||||
onClick={() => selectAccount(account)}
|
||||
onKeyDown={() => selectAccount(account)}
|
||||
className="group flex items-center gap-2 hover:bg-black/5 dark:hover:bg-white/5 p-3"
|
||||
>
|
||||
<User.Provider pubkey={account.replace("_nostrconnect", "")}>
|
||||
<User.Root className="flex-1 flex items-center gap-2.5">
|
||||
<User.Avatar className="rounded-full size-10" />
|
||||
{value === account && !value.includes("_nostrconnect") ? (
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") loginWith();
|
||||
}}
|
||||
disabled={isPending}
|
||||
placeholder="Password"
|
||||
className="px-3 rounded-full w-full h-10 bg-transparent border border-neutral-200 dark:border-neutral-500 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex flex-col items-start">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
|
||||
{account.includes("_nostrconnect") ? (
|
||||
<div className="text-[8px] border border-blue-500 text-blue-500 px-1.5 rounded-full">
|
||||
Nostr Connect
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{displayNpub(account.replace("_nostrconnect", ""), 16)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="inline-flex items-center justify-center size-8 shrink-0">
|
||||
{value === account ? (
|
||||
isPending ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loginWith()}
|
||||
className="rounded-full size-10 inline-flex items-center justify-center"
|
||||
>
|
||||
<ArrowRight className="size-5" />
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e, account)}
|
||||
className="rounded-full size-10 hidden group-hover:inline-flex items-center justify-center"
|
||||
>
|
||||
<DotsThree className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<a
|
||||
href="/new"
|
||||
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 p-3">
|
||||
<div className="inline-flex items-center justify-center rounded-full size-10 bg-neutral-200 dark:bg-white/10">
|
||||
<Plus className="size-5" />
|
||||
</div>
|
||||
<span className="truncate text-sm font-medium leading-tight">
|
||||
New account
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</Frame>
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<a
|
||||
href="/bootstrap-relays"
|
||||
className="h-8 w-max text-xs px-3 inline-flex items-center justify-center gap-1.5 bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 rounded-full"
|
||||
>
|
||||
<GearSix className="size-4" />
|
||||
Manage Relays
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { checkForAppUpdates } from "@/commons";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
beforeLoad: async () => {
|
||||
// Check for app updates
|
||||
await checkForAppUpdates(true);
|
||||
|
||||
// Get all accounts
|
||||
const accounts = await commands.getAccounts();
|
||||
|
||||
if (accounts.length < 1) {
|
||||
throw redirect({
|
||||
to: "/new",
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
accounts: accounts.filter((account) => !account.endsWith("Lume")),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { Frame, Spinner } from "@/components";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type RouteSearch = {
|
||||
account: string;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/loading")({
|
||||
validateSearch: (search: Record<string, string>): RouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const navigate = Route.useNavigate();
|
||||
const search = Route.useSearch();
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen("neg_synchronized", async () => {
|
||||
const status = await commands.createSyncFile(search.account);
|
||||
|
||||
if (status) {
|
||||
navigate({
|
||||
to: "/$account/home",
|
||||
// @ts-ignore, this is tanstack router bug
|
||||
params: { account: search.account },
|
||||
replace: true,
|
||||
});
|
||||
} else {
|
||||
throw new Error("System error.");
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="size-full flex items-center justify-center">
|
||||
<Frame
|
||||
className="p-6 h-36 flex flex-col gap-2 items-center justify-center text-center rounded-xl overflow-hidden"
|
||||
shadow
|
||||
>
|
||||
<Spinner />
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-40">
|
||||
Syncing all necessary data for the first time login...
|
||||
</p>
|
||||
</Frame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,8 @@ import {
|
||||
|
||||
export function MediaButton({
|
||||
setText,
|
||||
setAttaches,
|
||||
}: {
|
||||
setText: Dispatch<SetStateAction<string>>;
|
||||
setAttaches: Dispatch<SetStateAction<string[]>>;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
@@ -24,8 +22,6 @@ export function MediaButton({
|
||||
try {
|
||||
const image = await upload();
|
||||
setText((prev) => `${prev}\n${image}`);
|
||||
setAttaches((prev) => [...prev, image]);
|
||||
return;
|
||||
} catch (e) {
|
||||
await message(String(e), { title: "Upload", kind: "error" });
|
||||
return;
|
||||
@@ -44,7 +40,6 @@ export function MediaButton({
|
||||
if (isImagePath(item)) {
|
||||
const image = await upload(item);
|
||||
setText((prev) => `${prev}\n${image}`);
|
||||
setAttaches((prev) => [...prev, image]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
// @ts-nocheck
|
||||
import { type Mention, commands } from "@/commands.gen";
|
||||
import { cn } from "@/commons";
|
||||
import { Spinner } from "@/components";
|
||||
import { type Mention, type Result, commands } from "@/commands.gen";
|
||||
import { cn, displayNpub } from "@/commons";
|
||||
import { PublishIcon, Spinner } from "@/components";
|
||||
import { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
import { LumeEvent, useEvent } from "@/system";
|
||||
import { Feather } from "@phosphor-icons/react";
|
||||
import { LumeWindow, useEvent } from "@/system";
|
||||
import type { Metadata } from "@/types";
|
||||
import { CaretDown } from "@phosphor-icons/react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
||||
import type { Window } from "@tauri-apps/api/window";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
RichTextarea,
|
||||
@@ -45,7 +53,15 @@ const renderer = createRegexRenderer([
|
||||
],
|
||||
[
|
||||
/(?:^|\W)nostr:(\w+)(?!\w)/g,
|
||||
({ children, key, value }) => (
|
||||
({ children, key }) => (
|
||||
<span key={key} className="text-blue-500">
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
],
|
||||
[
|
||||
/(?:^|\W)#(\w+)(?!\w)/g,
|
||||
({ children, key }) => (
|
||||
<span key={key} className="text-blue-500">
|
||||
{children}
|
||||
</span>
|
||||
@@ -53,7 +69,7 @@ const renderer = createRegexRenderer([
|
||||
],
|
||||
]);
|
||||
|
||||
export const Route = createFileRoute("/editor/")({
|
||||
export const Route = createFileRoute("/new-post/")({
|
||||
validateSearch: (search: Record<string, string>): EditorSearch => {
|
||||
return {
|
||||
reply_to: search.reply_to,
|
||||
@@ -70,25 +86,28 @@ export const Route = createFileRoute("/editor/")({
|
||||
initialValue = "";
|
||||
}
|
||||
|
||||
const res = await commands.getMentionList();
|
||||
const res = await commands.getAllProfiles();
|
||||
const accounts = await commands.getAccounts();
|
||||
|
||||
if (res.status === "ok") {
|
||||
users = res.data;
|
||||
}
|
||||
|
||||
return { users, initialValue };
|
||||
return { accounts, users, initialValue };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { reply_to } = Route.useSearch();
|
||||
const { users, initialValue } = Route.useRouteContext();
|
||||
const { accounts, users, initialValue } = Route.useRouteContext();
|
||||
|
||||
const [text, setText] = useState("");
|
||||
const [currentUser, setCurrentUser] = useState<string>(null);
|
||||
const [popup, setPopup] = useState<Window>(null);
|
||||
const [isPublish, setIsPublish] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [attaches, setAttaches] = useState<string[]>(null);
|
||||
const [warning, setWarning] = useState({ enable: false, reason: "" });
|
||||
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
|
||||
const [index, setIndex] = useState<number>(0);
|
||||
@@ -110,6 +129,34 @@ function Screen() {
|
||||
[name],
|
||||
);
|
||||
|
||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const list = [];
|
||||
|
||||
for (const account of accounts) {
|
||||
const res = await commands.getProfile(account);
|
||||
let name = "unknown";
|
||||
|
||||
if (res.status === "ok") {
|
||||
const profile: Metadata = JSON.parse(res.data);
|
||||
name = profile.display_name ?? profile.name;
|
||||
}
|
||||
|
||||
list.push(
|
||||
MenuItem.new({
|
||||
text: `Publish as ${name} (${displayNpub(account, 16)})`,
|
||||
action: async () => setCurrentUser(account),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const items = await Promise.all(list);
|
||||
const menu = await Menu.new({ items });
|
||||
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
const insert = (i: number) => {
|
||||
if (!ref.current || !pos) return;
|
||||
|
||||
@@ -126,41 +173,84 @@ function Screen() {
|
||||
setIndex(0);
|
||||
};
|
||||
|
||||
const publish = async () => {
|
||||
const publish = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
// Temporary hide window
|
||||
await getCurrentWindow().hide();
|
||||
const content = text.trim();
|
||||
const warn = warning.enable ? warning.reason : undefined;
|
||||
const diff = difficulty.enable ? difficulty.num : undefined;
|
||||
|
||||
let res: Result<string, string>;
|
||||
let res: Result<string, string>;
|
||||
|
||||
if (reply_to) {
|
||||
res = await commands.reply(content, reply_to, root_to);
|
||||
} else {
|
||||
res = await commands.publish(content, warning, difficulty);
|
||||
}
|
||||
if (reply_to?.length) {
|
||||
res = await commands.reply(content, reply_to, undefined);
|
||||
} else {
|
||||
res = await commands.publish(content, warn, diff);
|
||||
}
|
||||
|
||||
if (res.status === "ok") {
|
||||
setText("");
|
||||
// Close window
|
||||
await getCurrentWindow().close();
|
||||
} else {
|
||||
setError(res.error);
|
||||
// Focus window
|
||||
await getCurrentWindow().setFocus();
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
if (res.status === "ok") {
|
||||
setText("");
|
||||
setIsPublish(true);
|
||||
} else {
|
||||
setError(res.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (currentUser) {
|
||||
const signer = await commands.hasSigner(currentUser);
|
||||
|
||||
if (signer.status === "ok") {
|
||||
if (!signer.data) {
|
||||
const newPopup = await LumeWindow.openPopup(
|
||||
`/set-signer/${currentUser}`,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
setPopup(newPopup);
|
||||
return;
|
||||
}
|
||||
|
||||
publish();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!popup) return;
|
||||
|
||||
const unlisten = popup.listen("signer-updated", () => {
|
||||
publish();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, [popup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPublish) {
|
||||
const timer = setTimeout(() => setIsPublish((prev) => !prev), 5000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [isPublish]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValue?.length) {
|
||||
setText(initialValue);
|
||||
}
|
||||
}, [initialValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accounts?.length) {
|
||||
setCurrentUser(accounts[0]);
|
||||
}
|
||||
}, [accounts]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<div data-tauri-drag-region className="h-11 shrink-0" />
|
||||
@@ -229,21 +319,24 @@ function Screen() {
|
||||
setIndex(0);
|
||||
}
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
{renderer}
|
||||
</RichTextarea>
|
||||
{pos
|
||||
? createPortal(
|
||||
<Menu
|
||||
top={pos.top}
|
||||
left={pos.left}
|
||||
users={filtered}
|
||||
index={index}
|
||||
insert={insert}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{pos ? (
|
||||
createPortal(
|
||||
<MentionPopup
|
||||
top={pos.top}
|
||||
left={pos.left}
|
||||
users={filtered}
|
||||
index={index}
|
||||
insert={insert}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{warning.enable ? (
|
||||
@@ -289,20 +382,44 @@ function Screen() {
|
||||
data-tauri-drag-region
|
||||
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => publish()}
|
||||
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Feather className="size-4" weight="fill" />
|
||||
)}
|
||||
Publish
|
||||
</button>
|
||||
<div className="inline-flex items-center flex-1 gap-2 pl-4">
|
||||
<MediaButton setText={setText} setAttaches={setAttaches} />
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg w-max",
|
||||
isPublish
|
||||
? "bg-green-500 hover:bg-green-600 dark:hover:bg-green-400 text-white"
|
||||
: "bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<PublishIcon className="size-4" />
|
||||
)}
|
||||
{isPublish ? "Published" : "Publish"}
|
||||
</button>
|
||||
{currentUser ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<User.Provider pubkey={currentUser}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-6 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<CaretDown
|
||||
className="mt-px size-3 text-neutral-500"
|
||||
weight="bold"
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="inline-flex items-center flex-1 gap-2 pl-2">
|
||||
<MediaButton setText={setText} />
|
||||
<WarningButton setWarning={setWarning} />
|
||||
<PowButton setDifficulty={setDifficulty} />
|
||||
</div>
|
||||
@@ -311,7 +428,7 @@ function Screen() {
|
||||
);
|
||||
}
|
||||
|
||||
function Menu({
|
||||
function MentionPopup({
|
||||
users,
|
||||
index,
|
||||
top,
|
||||
@@ -19,24 +19,36 @@ function Screen() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<a
|
||||
href="/auth/connect"
|
||||
className="w-full p-4 rounded-xl hover:shadow-lg hover:ring-0 hover:bg-white dark:hover:bg-neutral-900 ring-1 ring-black/5 dark:ring-white/5"
|
||||
className="w-full p-4 rounded-xl hover:shadow-lg hover:ring-0 hover:bg-white dark:hover:bg-neutral-800 ring-1 ring-black/5 dark:ring-white/5"
|
||||
>
|
||||
<h3 className="mb-1 font-medium">Continue with Nostr Connect</h3>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-600">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-500">
|
||||
Your account will be handled by a remote signer. Lume will not
|
||||
store your account keys.
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
href="/auth/import"
|
||||
className="w-full p-4 rounded-xl hover:shadow-lg hover:ring-0 hover:bg-white dark:hover:bg-neutral-900 ring-1 ring-black/5 dark:ring-white/5"
|
||||
className="w-full p-4 rounded-xl hover:shadow-lg hover:ring-0 hover:bg-white dark:hover:bg-neutral-800 ring-1 ring-black/5 dark:ring-white/5"
|
||||
>
|
||||
<h3 className="mb-1 font-medium">Continue with Secret Key</h3>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-600">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-500">
|
||||
Lume will store your keys in secure storage. You can provide a
|
||||
password to add extra security.
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
href="/auth/watch"
|
||||
className="w-full p-4 rounded-xl hover:shadow-lg hover:ring-0 hover:bg-white dark:hover:bg-neutral-800 ring-1 ring-black/5 dark:ring-white/5"
|
||||
>
|
||||
<h3 className="mb-1 font-medium">
|
||||
Continue with Public Key (Watch Mode)
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-500">
|
||||
Use for experience without provide your private key, you can add
|
||||
it later to publish new note.
|
||||
</p>
|
||||
</a>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 h-px bg-black/5 dark:bg-white/5" />
|
||||
<div className="shrink-0 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
|
||||
92
src/routes/set-signer.$id.lazy.tsx
Normal file
92
src/routes/set-signer.$id.lazy.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { Spinner, User } from "@/components";
|
||||
import { Lock } from "@phosphor-icons/react";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
export const Route = createLazyFileRoute("/set-signer/$id")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { id } = Route.useParams();
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const unlock = () => {
|
||||
startTransition(async () => {
|
||||
if (!password.length) {
|
||||
await message("Password is required", { kind: "info" });
|
||||
return;
|
||||
}
|
||||
|
||||
const window = getCurrentWindow();
|
||||
const res = await commands.setSigner(id, password);
|
||||
|
||||
if (res.status === "ok") {
|
||||
await window.close();
|
||||
} else {
|
||||
await message(res.error, { kind: "error" });
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="size-full flex flex-col items-center justify-between gap-6 p-3"
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex-1 w-full px-10 flex flex-col gap-6 items-center justify-center"
|
||||
>
|
||||
<User.Provider pubkey={id}>
|
||||
<User.Root className="flex flex-col text-center gap-2">
|
||||
<User.Avatar className="size-12 rounded-full" />
|
||||
<User.Name className="font-semibold" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="w-full flex flex-col gap-2 items-center justify-center">
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") unlock();
|
||||
}}
|
||||
disabled={isPending}
|
||||
placeholder="Enter password to unlock"
|
||||
className="px-3 w-full rounded-lg h-10 text-center bg-transparent border border-black/10 dark:border-white/10 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unlock()}
|
||||
disabled={isPending}
|
||||
className="shrink-0 h-9 w-full rounded-lg inline-flex items-center justify-center gap-2 bg-blue-500 hover:bg-blue-600 dark:hover:bg-blue-400 text-white text-sm font-medium"
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Lock className="size-4" weight="bold" />
|
||||
)}
|
||||
Unlock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => getCurrentWindow().close()}
|
||||
className="text-sm font-medium text-red-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createLazyFileRoute("/$account/_settings")({
|
||||
export const Route = createLazyFileRoute("/settings/$id")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
const { id } = Route.useParams();
|
||||
const { platform } = Route.useRouteContext();
|
||||
|
||||
return (
|
||||
@@ -17,11 +17,14 @@ function Screen() {
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={cn(
|
||||
"w-[250px] shrink-0 flex flex-col gap-1 border-r border-black/10 dark:border-white/10 p-2",
|
||||
"w-[200px] shrink-0 flex flex-col gap-1 border-r border-black/10 dark:border-white/10 p-2",
|
||||
platform === "macos" ? "pt-11" : "",
|
||||
)}
|
||||
>
|
||||
<Link to="/$account/general" params={{ account }}>
|
||||
<div className="h-8 px-1.5">
|
||||
<h1 className="text-lg font-semibold">Settings</h1>
|
||||
</div>
|
||||
<Link to="/settings/$id/general" params={{ id }}>
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<div
|
||||
@@ -38,7 +41,7 @@ function Screen() {
|
||||
);
|
||||
}}
|
||||
</Link>
|
||||
<Link to="/$account/profile" params={{ account }}>
|
||||
<Link to="/settings/$id/profile" params={{ id }}>
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<div
|
||||
@@ -55,7 +58,7 @@ function Screen() {
|
||||
);
|
||||
}}
|
||||
</Link>
|
||||
<Link to="/$account/relay" params={{ account }}>
|
||||
<Link to="/settings/$id/relay" params={{ id }}>
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<div
|
||||
@@ -72,7 +75,7 @@ function Screen() {
|
||||
);
|
||||
}}
|
||||
</Link>
|
||||
<Link to="/$account/wallet" params={{ account }}>
|
||||
<Link to="/settings/$id/wallet" params={{ id }}>
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<div
|
||||
189
src/routes/settings.$id/general.lazy.tsx
Normal file
189
src/routes/settings.$id/general.lazy.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { commands } from '@/commands.gen'
|
||||
import { appSettings } from '@/commons'
|
||||
import { Spinner } from '@/components'
|
||||
import * as Switch from '@radix-ui/react-switch'
|
||||
import { createLazyFileRoute } from '@tanstack/react-router'
|
||||
import { useStore } from '@tanstack/react-store'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { useCallback, useEffect, useState, useTransition } from 'react'
|
||||
|
||||
type Theme = 'auto' | 'light' | 'dark'
|
||||
|
||||
export const Route = createLazyFileRoute('/settings/$id/general')({
|
||||
component: Screen,
|
||||
})
|
||||
|
||||
function Screen() {
|
||||
const [theme, setTheme] = useState<Theme>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const changeTheme = useCallback(async (theme: string) => {
|
||||
if (theme === 'auto' || theme === 'light' || theme === 'dark') {
|
||||
invoke('plugin:theme|set_theme', {
|
||||
theme: theme,
|
||||
}).then(() => setTheme(theme))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateSettings = () => {
|
||||
startTransition(async () => {
|
||||
const newSettings = JSON.stringify(appSettings.state)
|
||||
const res = await commands.setUserSettings(newSettings)
|
||||
|
||||
if (res.status === 'error') {
|
||||
await message(res.error, { kind: 'error' })
|
||||
}
|
||||
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
invoke('plugin:theme|get_theme').then((data) => setTheme(data as Theme))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="flex flex-col gap-6 px-3 pb-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
General
|
||||
</h2>
|
||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<Setting
|
||||
name="Relay Hint"
|
||||
description="Use the relay hint if necessary."
|
||||
label="use_relay_hint"
|
||||
/>
|
||||
<Setting
|
||||
name="Content Warning"
|
||||
description="Shows a warning for notes that have a content warning."
|
||||
label="content_warning"
|
||||
/>
|
||||
<Setting
|
||||
name="Trusted Only"
|
||||
description="Only shows note's replies from your inner circle."
|
||||
label="trusted_only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
Appearance
|
||||
</h2>
|
||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Appearance</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Change app theme
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<select
|
||||
name="theme"
|
||||
className="w-24 py-1 bg-transparent rounded-lg shadow-none outline-none border-1 border-black/10 dark:border-white/10"
|
||||
defaultValue={theme}
|
||||
onChange={(e) => changeTheme(e.target.value)}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Setting
|
||||
name="Transparent Effect"
|
||||
description="Use native window transparent effect."
|
||||
label="transparent"
|
||||
/>
|
||||
<Setting
|
||||
name="Show Zap Button"
|
||||
description="Shows the Zap button when viewing a note."
|
||||
label="display_zap_button"
|
||||
/>
|
||||
<Setting
|
||||
name="Show Repost Button"
|
||||
description="Shows the Repost button when viewing a note."
|
||||
label="display_repost_button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
Privacy & Performance
|
||||
</h2>
|
||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<Setting
|
||||
name="Proxy"
|
||||
description="Set proxy address."
|
||||
label="proxy"
|
||||
/>
|
||||
<Setting
|
||||
name="Image Resize Service"
|
||||
description="Use weserv/images for resize image on-the-fly."
|
||||
label="image_resize_service"
|
||||
/>
|
||||
<Setting
|
||||
name="Load Remote Media"
|
||||
description="View the remote media directly."
|
||||
label="display_media"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 left-0 w-full h-16 flex items-center justify-end px-3">
|
||||
<div className="absolute left-0 bottom-0 w-full h-11 gradient-mask-t-0 bg-neutral-100 dark:bg-neutral-900" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateSettings()}
|
||||
className="relative z-10 inline-flex items-center justify-center w-20 rounded-md shadow h-8 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium"
|
||||
>
|
||||
{isPending ? <Spinner className="size-4" /> : 'Update'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Setting({
|
||||
label,
|
||||
name,
|
||||
description,
|
||||
}: {
|
||||
label: string
|
||||
name: string
|
||||
description: string
|
||||
}) {
|
||||
const state = useStore(appSettings, (state) => state[label])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
appSettings.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
[label]: !state[label],
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{name}</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={state}
|
||||
onClick={() => toggle()}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<Switch.Thumb className="block size-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>
|
||||
)
|
||||
}
|
||||
17
src/routes/settings.$id/general.tsx
Normal file
17
src/routes/settings.$id/general.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { commands } from '@/commands.gen'
|
||||
import { appSettings } from '@/commons'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/settings/$id/general')({
|
||||
beforeLoad: async () => {
|
||||
const res = await commands.getUserSettings()
|
||||
|
||||
if (res.status === 'ok') {
|
||||
appSettings.setState((state) => {
|
||||
return { ...state, ...res.data }
|
||||
})
|
||||
} else {
|
||||
throw new Error(res.error)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export const Route = createLazyFileRoute("/$account/_settings/profile")({
|
||||
export const Route = createLazyFileRoute("/settings/$id/profile")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
@@ -174,14 +174,14 @@ function Screen() {
|
||||
}
|
||||
|
||||
function PrivkeyButton() {
|
||||
const { account } = Route.useParams();
|
||||
const { id } = Route.useParams();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isCopy, setIsCopy] = useState(false);
|
||||
|
||||
const copyPrivateKey = () => {
|
||||
startTransition(async () => {
|
||||
const res = await commands.getPrivateKey(account);
|
||||
const res = await commands.getPrivateKey(id);
|
||||
|
||||
if (res.status === "ok") {
|
||||
await writeText(res.data);
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type Profile, commands } from "@/commands.gen";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/$account/_settings/profile")({
|
||||
export const Route = createFileRoute("/settings/$id/profile")({
|
||||
beforeLoad: async ({ params }) => {
|
||||
const res = await commands.getProfile(params.account);
|
||||
const res = await commands.getProfile(params.id);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const profile: Profile = JSON.parse(res.data);
|
||||
@@ -4,7 +4,7 @@ import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
|
||||
export const Route = createLazyFileRoute("/$account/_settings/relay")({
|
||||
export const Route = createLazyFileRoute("/settings/$id/relay")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ function Screen() {
|
||||
const { relayList } = Route.useRouteContext();
|
||||
|
||||
const [relays, setRelays] = useState<string[]>([]);
|
||||
const [newRelay, setNewRelay] = useState("");
|
||||
const [newRelay, setNewRelay] = useState<string>("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const removeRelay = async (relay: string) => {
|
||||
@@ -93,6 +93,7 @@ function Screen() {
|
||||
onChange={(e) => setNewRelay(e.target.value)}
|
||||
name="url"
|
||||
placeholder="wss://..."
|
||||
disabled={isPending}
|
||||
spellCheck={false}
|
||||
className="flex-1 px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:border-neutral-700 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
14
src/routes/settings.$id/relay.tsx
Normal file
14
src/routes/settings.$id/relay.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/settings/$id/relay")({
|
||||
beforeLoad: async ({ params }) => {
|
||||
const res = await commands.getRelays(params.id);
|
||||
|
||||
if (res.status === "ok") {
|
||||
return { relayList: res.data };
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
},
|
||||
});
|
||||
50
src/routes/settings.$id/wallet.lazy.tsx
Normal file
50
src/routes/settings.$id/wallet.lazy.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { Button } from "@getalby/bitcoin-connect-react";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createLazyFileRoute("/settings/$id/wallet")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const [_isConnect, setIsConnect] = useState(false);
|
||||
|
||||
const setWallet = async (uri: string) => {
|
||||
const res = await commands.setWallet(uri);
|
||||
|
||||
if (res.status === "ok") {
|
||||
setIsConnect((prev) => !prev);
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const removeWallet = async () => {
|
||||
const res = await commands.removeWallet();
|
||||
|
||||
if (res.status === "ok") {
|
||||
window.localStorage.removeItem("bc:config");
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full px-3 pb-3">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
Wallet
|
||||
</h2>
|
||||
<div className="w-full h-44 flex items-center justify-center bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<Button
|
||||
onConnected={(provider) =>
|
||||
setWallet(provider.client.nostrWalletConnectUrl)
|
||||
}
|
||||
onDisconnected={() => removeWallet()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { init } from "@getalby/bitcoin-connect-react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/$account/_settings/bitcoin-connect")({
|
||||
beforeLoad: () => {
|
||||
export const Route = createFileRoute("/settings/$id/wallet")({
|
||||
beforeLoad: async () => {
|
||||
init({
|
||||
appName: "Lume",
|
||||
filters: ["nwc"],
|
||||
@@ -1,8 +1,14 @@
|
||||
import { User } from "@/components/user";
|
||||
import { commands } from "@/commands.gen";
|
||||
import { displayNpub } from "@/commons";
|
||||
import { User } from "@/components";
|
||||
import { LumeWindow } from "@/system";
|
||||
import type { Metadata } from "@/types";
|
||||
import { CaretDown } from "@phosphor-icons/react";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
||||
import { type Window, getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useCallback, useEffect, useState, useTransition } from "react";
|
||||
import CurrencyInput from "react-currency-input-field";
|
||||
|
||||
const DEFAULT_VALUES = [21, 50, 100, 200];
|
||||
@@ -12,38 +18,102 @@ export const Route = createLazyFileRoute("/zap/$id")({
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { event } = Route.useRouteContext();
|
||||
const { accounts, event } = Route.useRouteContext();
|
||||
|
||||
const [currentUser, setCurrentUser] = useState<string>(null);
|
||||
const [popup, setPopup] = useState<Window>(null);
|
||||
const [amount, setAmount] = useState(21);
|
||||
const [content, setContent] = useState("");
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const submit = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const val = await event.zap(amount, content);
|
||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (val) {
|
||||
setIsCompleted(true);
|
||||
// close current window
|
||||
await getCurrentWebviewWindow().close();
|
||||
}
|
||||
} catch (e) {
|
||||
await message(String(e), {
|
||||
title: "Zap",
|
||||
kind: "error",
|
||||
});
|
||||
const list = [];
|
||||
|
||||
for (const account of accounts) {
|
||||
const res = await commands.getProfile(account);
|
||||
let name = "unknown";
|
||||
|
||||
if (res.status === "ok") {
|
||||
const profile: Metadata = JSON.parse(res.data);
|
||||
name = profile.display_name ?? profile.name;
|
||||
}
|
||||
|
||||
list.push(
|
||||
MenuItem.new({
|
||||
text: `Zap as ${name} (${displayNpub(account, 16)})`,
|
||||
action: async () => setCurrentUser(account),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const items = await Promise.all(list);
|
||||
const menu = await Menu.new({ items });
|
||||
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
const zap = () => {
|
||||
startTransition(async () => {
|
||||
const res = await commands.zapEvent(event.id, amount.toString(), content);
|
||||
|
||||
if (res.status === "ok") {
|
||||
setIsCompleted(true);
|
||||
// close current window
|
||||
await getCurrentWindow().close();
|
||||
} else {
|
||||
await message(res.error, { kind: "error" });
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (currentUser) {
|
||||
const signer = await commands.hasSigner(currentUser);
|
||||
|
||||
if (signer.status === "ok") {
|
||||
if (!signer.data) {
|
||||
const newPopup = await LumeWindow.openPopup(
|
||||
`/set-signer/${currentUser}`,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
setPopup(newPopup);
|
||||
return;
|
||||
}
|
||||
|
||||
zap();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!popup) return;
|
||||
|
||||
const unlisten = popup.listen("signer-updated", () => {
|
||||
zap();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, [popup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accounts?.length) {
|
||||
setCurrentUser(accounts[0]);
|
||||
}
|
||||
}, [accounts]);
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex flex-col pb-5 size-full">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex items-center justify-center h-24 gap-2 shrink-0"
|
||||
className="flex items-center justify-center h-32 gap-2 shrink-0"
|
||||
>
|
||||
<p className="text-sm">Send zap to </p>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
@@ -95,15 +165,34 @@ function Screen() {
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
placeholder="Enter message (optional)"
|
||||
className="h-11 w-full resize-none rounded-xl border-transparent bg-black/5 px-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/5"
|
||||
className="h-10 w-full resize-none rounded-lg border-transparent bg-black/5 px-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex items-center justify-center w-full h-10 font-medium rounded-xl bg-neutral-950 text-neutral-50 hover:bg-neutral-900 dark:bg-white/20 dark:hover:bg-white/30"
|
||||
>
|
||||
{isCompleted ? "Zapped" : isPending ? "Processing..." : "Zap"}
|
||||
</button>
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex items-center justify-center w-full h-9 text-sm font-semibold rounded-lg bg-blue-500 text-white hover:bg-blue-600 dark:hover:bg-blue-400"
|
||||
>
|
||||
{isCompleted ? "Zapped" : isPending ? "Processing..." : "Zap"}
|
||||
</button>
|
||||
{currentUser ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<User.Provider pubkey={currentUser}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-6 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<CaretDown
|
||||
className="mt-px size-3 text-neutral-500"
|
||||
weight="bold"
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/zap/$id")({
|
||||
beforeLoad: async ({ params }) => {
|
||||
const accounts = await commands.getAccounts();
|
||||
const res = await commands.getEvent(params.id);
|
||||
|
||||
if (res.status === "ok") {
|
||||
@@ -15,7 +16,7 @@ export const Route = createFileRoute("/zap/$id")({
|
||||
raw.meta = data.parsed;
|
||||
}
|
||||
|
||||
return { event: new LumeEvent(raw) };
|
||||
return { accounts, event: new LumeEvent(raw) };
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user