feat: improve multi-account switching

This commit is contained in:
reya
2024-05-15 10:14:21 +07:00
parent f1e17ff3c4
commit b60d4db0df
13 changed files with 706 additions and 720 deletions

View File

@@ -1,4 +1,4 @@
import { User } from "./user"; import { User } from "@/components/user";
import { getBitcoinDisplayValues } from "@lume/utils"; import { getBitcoinDisplayValues } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router"; import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";

View File

@@ -1,160 +0,0 @@
import { CancelIcon, CheckIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types";
import { cn } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core";
import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { useEffect, useRef, useState } from "react";
export function Col({
column,
account,
isScroll,
isResize,
}: {
column: LumeColumn;
account: string;
isScroll: boolean;
isResize: boolean;
}) {
const container = useRef<HTMLDivElement>(null);
const [webview, setWebview] = useState<string | undefined>(undefined);
const repositionWebview = async () => {
if (webview && webview.length > 1) {
const newRect = container.current.getBoundingClientRect();
await invoke("reposition_column", {
label: webview,
x: newRect.x,
y: newRect.y,
});
}
};
const resizeWebview = async () => {
if (webview && webview.length > 1) {
const newRect = container.current.getBoundingClientRect();
await invoke("resize_column", {
label: webview,
width: newRect.width,
height: newRect.height,
});
}
};
useEffect(() => {
resizeWebview();
}, [isResize]);
useEffect(() => {
if (isScroll) repositionWebview();
}, [isScroll]);
useEffect(() => {
(async () => {
if (webview && webview.length > 1) return;
const rect = container.current.getBoundingClientRect();
const windowLabel = `column-${column.label}`;
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
// create new webview
const label: string = await invoke("create_column", {
label: windowLabel,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
url,
});
setWebview(label);
})();
// close webview when unmounted
return () => {
if (webview && webview.length > 1) {
invoke("close_column", {
label: webview,
});
}
};
}, [webview]);
return (
<div className="h-full w-[440px] shrink-0 p-2">
<div
className={cn(
"flex flex-col w-full h-full rounded-xl",
column.label !== "open"
? "bg-black/5 dark:bg-white/5 backdrop-blur-sm"
: "",
)}
>
{column.label !== "open" ? (
<Header label={column.label} name={column.name} />
) : null}
<div ref={container} className="flex-1 w-full h-full" />
</div>
</div>
);
}
function Header({ label, name }: { label: string; name: string }) {
const [title, setTitle] = useState(name);
const [isChanged, setIsChanged] = useState(false);
const saveNewTitle = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "set_title", label, title });
// update search params
// @ts-ignore, hahaha
search.name = title;
// reset state
setIsChanged(false);
};
const close = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "remove", label });
};
useEffect(() => {
if (title.length !== name.length) setIsChanged(true);
}, [title]);
return (
<div className="h-9 w-full flex items-center justify-between shrink-0 px-1">
<div className="size-7" />
<div className="shrink-0 h-9 flex items-center justify-center">
<div className="relative flex gap-2 items-center">
<div
contentEditable
suppressContentEditableWarning={true}
onBlur={(e) => setTitle(e.currentTarget.textContent)}
className="text-sm font-medium focus:outline-none"
>
{name}
</div>
{isChanged ? (
<button
type="button"
onClick={() => saveNewTitle()}
className="text-teal-500 hover:text-teal-600"
>
<CheckIcon className="size-4" />
</button>
) : null}
</div>
</div>
<button
type="button"
onClick={() => close()}
className="size-7 inline-flex hover:bg-black/10 rounded-lg dark:hover:bg-white/10 items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
>
<CancelIcon className="size-4" />
</button>
</div>
);
}

View File

@@ -0,0 +1,147 @@
import { CancelIcon, CheckIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types";
import { cn } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core";
import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { useEffect, useRef, useState } from "react";
export function Column({
column,
account,
isScroll,
isResize,
}: {
column: LumeColumn;
account: string;
isScroll: boolean;
isResize: boolean;
}) {
const container = useRef<HTMLDivElement>(null);
const webviewLabel = `column-${account}_${column.label}`;
const [isCreated, setIsCreated] = useState(false);
const repositionWebview = async () => {
const newRect = container.current.getBoundingClientRect();
await invoke("reposition_column", {
label: webviewLabel,
x: newRect.x,
y: newRect.y,
});
};
const resizeWebview = async () => {
const newRect = container.current.getBoundingClientRect();
await invoke("resize_column", {
label: webviewLabel,
width: newRect.width,
height: newRect.height,
});
};
useEffect(() => {
if (isCreated) resizeWebview();
}, [isResize]);
useEffect(() => {
if (isScroll && isCreated) repositionWebview();
}, [isScroll]);
useEffect(() => {
const rect = container.current.getBoundingClientRect();
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
// create new webview
invoke("create_column", {
label: webviewLabel,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
url,
}).then(() => setIsCreated(true));
// close webview when unmounted
return () => {
invoke("close_column", { label: webviewLabel });
};
}, [account]);
return (
<div className="h-full w-[440px] shrink-0 p-2">
<div
className={cn(
"flex flex-col w-full h-full rounded-xl",
column.label !== "open"
? "bg-black/5 dark:bg-white/5 backdrop-blur-sm"
: "",
)}
>
{column.label !== "open" ? (
<Header label={column.label} name={column.name} />
) : null}
<div ref={container} className="flex-1 w-full h-full" />
</div>
</div>
);
}
function Header({ label, name }: { label: string; name: string }) {
const [title, setTitle] = useState(name);
const [isChanged, setIsChanged] = useState(false);
const saveNewTitle = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "set_title", label, title });
// update search params
// @ts-ignore, hahaha
search.name = title;
// reset state
setIsChanged(false);
};
const close = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "remove", label });
};
useEffect(() => {
if (title.length !== name.length) setIsChanged(true);
}, [title]);
return (
<div className="h-9 w-full flex items-center justify-between shrink-0 px-1">
<div className="size-7" />
<div className="shrink-0 h-9 flex items-center justify-center">
<div className="relative flex gap-2 items-center">
<div
contentEditable
suppressContentEditableWarning={true}
onBlur={(e) => setTitle(e.currentTarget.textContent)}
className="text-sm font-medium focus:outline-none"
>
{name}
</div>
{isChanged ? (
<button
type="button"
onClick={() => saveNewTitle()}
className="text-teal-500 hover:text-teal-600"
>
<CheckIcon className="size-4" />
</button>
) : null}
</div>
</div>
<button
type="button"
onClick={() => close()}
className="size-7 inline-flex hover:bg-black/10 rounded-lg dark:hover:bg-white/10 items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
>
<CancelIcon className="size-4" />
</button>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { ThreadIcon } from "@lume/icons"; import { ThreadIcon } from "@lume/icons";
import type { Event } from "@lume/types"; import type { Event } from "@lume/types";
import { Note } from "./note"; import { Note } from "@/components/note";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router"; import { useRouteContext } from "@tanstack/react-router";

View File

@@ -1,5 +1,5 @@
import type { Event } from "@lume/types"; import type { Event } from "@lume/types";
import { Note } from "./note"; import { Note } from "@/components/note";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
export function Notification({ export function Notification({

View File

@@ -1,6 +1,6 @@
import { QuoteIcon } from "@lume/icons"; import { QuoteIcon } from "@lume/icons";
import type { Event } from "@lume/types"; import type { Event } from "@lume/types";
import { Note } from "./note"; import { Note } from "@/components/note";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
export function Quote({ export function Quote({

View File

@@ -1,7 +1,7 @@
import type { Event } from "@lume/types"; import type { Event } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { Note } from "./note"; import { Note } from "@/components/note";
import { User } from "./user"; import { User } from "@/components/user";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router"; import { useRouteContext } from "@tanstack/react-router";

View File

@@ -1,6 +1,6 @@
import type { Event } from "@lume/types"; import type { Event } from "@lume/types";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Note } from "./note"; import { Note } from "@/components/note";
export function TextNote({ export function TextNote({
event, event,

View File

@@ -1,4 +1,4 @@
import { Col } from "@/components/col"; import { Column } from "@/components/column";
import { Toolbar } from "@/components/toolbar"; import { Toolbar } from "@/components/toolbar";
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import type { EventColumns, LumeColumn } from "@lume/types"; import type { EventColumns, LumeColumn } from "@lume/types";
@@ -13,20 +13,19 @@ import { useDebouncedCallback } from "use-debounce";
import { VList, type VListHandle } from "virtua"; import { VList, type VListHandle } from "virtua";
export const Route = createFileRoute("/$account/home")({ export const Route = createFileRoute("/$account/home")({
beforeLoad: async ({ context }) => { loader: async ({ context }) => {
try { try {
const ark = context.ark; const userColumns = await context.ark.get_columns();
const resourcePath = await resolveResource( if (userColumns.length > 0) {
"resources/system_columns.json", return userColumns;
); } else {
const systemColumns: LumeColumn[] = JSON.parse( const systemPath = "resources/system_columns.json";
await readTextFile(resourcePath), const resourcePath = await resolveResource(systemPath);
); const resourceFile = await readTextFile(resourcePath);
const userColumns = await ark.get_columns(); const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
return { return systemColumns;
storedColumns: !userColumns.length ? systemColumns : userColumns, }
};
} catch (e) { } catch (e) {
console.error(String(e)); console.error(String(e));
} }
@@ -35,13 +34,14 @@ export const Route = createFileRoute("/$account/home")({
}); });
function Screen() { function Screen() {
const userSavedColumns = Route.useLoaderData();
const vlistRef = useRef<VListHandle>(null); const vlistRef = useRef<VListHandle>(null);
const { account } = Route.useParams(); const { account } = Route.useParams();
const { ark, storedColumns } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
const [selectedIndex, setSelectedIndex] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(-1);
const [columns, setColumns] = useState(storedColumns); const [columns, setColumns] = useState([]);
const [isScroll, setIsScroll] = useState(false); const [isScroll, setIsScroll] = useState(false);
const [isResize, setIsResize] = useState(false); const [isResize, setIsResize] = useState(false);
@@ -114,6 +114,10 @@ function Screen() {
150, 150,
); );
useEffect(() => {
setColumns(userSavedColumns);
}, [userSavedColumns]);
useEffect(() => { useEffect(() => {
// save state // save state
ark.set_columns(columns); ark.set_columns(columns);
@@ -148,9 +152,10 @@ function Screen() {
onScroll={() => setIsScroll(true)} onScroll={() => setIsScroll(true)}
onScrollEnd={() => setIsScroll(false)} onScrollEnd={() => setIsScroll(false)}
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none" className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
cache={null}
> >
{columns.map((column) => ( {columns.map((column) => (
<Col <Column
key={column.label} key={column.label}
column={column} column={column}
account={account} account={account}

View File

@@ -11,6 +11,7 @@ import { Outlet, createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getCurrent } from "@tauri-apps/api/window"; import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/$account")({ export const Route = createFileRoute("/$account")({
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
@@ -78,12 +79,17 @@ function Accounts() {
const { account } = Route.useParams(); const { account } = Route.useParams();
const changeAccount = async (npub: string) => { const changeAccount = async (npub: string) => {
if (npub === account) return; if (npub === account) {
return await ark.open_profile(account);
}
// change current account and update signer
const select = await ark.load_selected_account(npub); const select = await ark.load_selected_account(npub);
if (select) { if (select) {
return navigate({ to: "/$account/home", params: { account: npub } }); return navigate({ to: "/$account/home", params: { account: npub } });
} else {
toast.warning("Something wrong.");
} }
}; };
@@ -94,7 +100,7 @@ function Accounts() {
<User.Provider pubkey={user}> <User.Provider pubkey={user}>
<User.Root <User.Root
className={cn( className={cn(
"rounded-full", "rounded-full transition-all ease-in-out duration-150 will-change-auto",
user === account user === account
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950" ? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
: "", : "",
@@ -102,7 +108,7 @@ function Accounts() {
> >
<User.Avatar <User.Avatar
className={cn( className={cn(
"aspect-square h-auto rounded-full object-cover", "aspect-square h-auto rounded-full object-cover transition-all ease-in-out duration-150 will-change-auto",
user === account ? "w-7" : "w-8", user === account ? "w-7" : "w-8",
)} )}
/> />

View File

@@ -1 +1 @@
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","splash","settings","search","nwc","activity","zap-*","event-*","user-*","editor-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","updater:default","updater:allow-check","updater:allow-download-and-install","window:allow-start-dragging","window:allow-create","window:allow-close","window:allow-set-focus","clipboard-manager:allow-write","clipboard-manager:allow-read","webview:allow-create-webview-window","webview:allow-create-webview","webview:allow-set-webview-size","webview:allow-set-webview-position","webview:allow-webview-close","dialog:default","dialog:allow-ask","dialog:allow-message","fs:allow-read-file","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["linux","macOS","windows"]}} {"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","splash","settings","search","nwc","activity","zap-*","event-*","user-*","editor-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","updater:default","updater:allow-check","updater:allow-download-and-install","window:allow-start-dragging","window:allow-create","window:allow-close","window:allow-set-focus","clipboard-manager:allow-write","clipboard-manager:allow-read","webview:allow-create-webview-window","webview:allow-create-webview","webview:allow-set-webview-size","webview:allow-set-webview-position","webview:allow-webview-close","dialog:default","dialog:allow-ask","dialog:allow-message","process:allow-restart","fs:allow-read-file","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["linux","macOS","windows"]}}

View File

@@ -289,7 +289,7 @@ pub async fn unfollow(id: &str, state: State<'_, Nostr>) -> Result<EventId, Stri
} }
} }
#[tauri::command] #[tauri::command(async)]
pub async fn set_nstore( pub async fn set_nstore(
key: &str, key: &str,
content: &str, content: &str,
@@ -306,10 +306,7 @@ pub async fn set_nstore(
let builder = EventBuilder::new(Kind::ApplicationSpecificData, encrypted, vec![tag]); let builder = EventBuilder::new(Kind::ApplicationSpecificData, encrypted, vec![tag]);
match client.send_event_builder(builder).await { match client.send_event_builder(builder).await {
Ok(event_id) => { Ok(event_id) => Ok(event_id),
println!("set nstore: {}", event_id);
Ok(event_id)
}
Err(err) => Err(err.to_string()), Err(err) => Err(err.to_string()),
} }
} }
@@ -322,38 +319,29 @@ pub async fn get_nstore(key: &str, state: State<'_, Nostr>) -> Result<String, St
let client = &state.client; let client = &state.client;
if let Ok(signer) = client.signer().await { if let Ok(signer) = client.signer().await {
let public_key = signer.public_key().await; let public_key = signer.public_key().await.unwrap();
if let Ok(author) = public_key {
let filter = Filter::new() let filter = Filter::new()
.author(author) .author(public_key)
.kind(Kind::ApplicationSpecificData) .kind(Kind::ApplicationSpecificData)
.identifier(key) .identifier(key)
.limit(1); .limit(1);
let query = client match client
.get_events_of(vec![filter], Some(Duration::from_secs(10))) .get_events_of(vec![filter], Some(Duration::from_secs(5)))
.await; .await
{
if let Ok(events) = query { Ok(events) => {
if let Some(event) = events.first() { if let Some(event) = events.first() {
println!("get nstore key: {} - received: {}", key, event.id);
let content = event.content(); let content = event.content();
match signer.nip44_decrypt(public_key, content).await {
match signer.nip44_decrypt(author, content).await {
Ok(decrypted) => Ok(decrypted), Ok(decrypted) => Ok(decrypted),
Err(_) => Err(event.content.to_string()), Err(_) => Err(event.content.to_string()),
} }
} else { } else {
println!("get nstore key: {}", key);
Err("Value not found".into()) Err("Value not found".into())
} }
} else {
Err("Query nstore event failed".into())
} }
} else { Err(err) => Err(err.to_string()),
Err("Something is wrong".into())
} }
} else { } else {
Err("Signer is required".into()) Err("Signer is required".into())