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,42 +1,42 @@
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";
export function Balance({ account }: { account: string }) { export function Balance({ account }: { account: string }) {
const { ark } = useRouteContext({ strict: false }); const { ark } = useRouteContext({ strict: false });
const [balance, setBalance] = useState(0); const [balance, setBalance] = useState(0);
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]); const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
useEffect(() => { useEffect(() => {
async function getBalance() { async function getBalance() {
const val = await ark.get_balance(); const val = await ark.get_balance();
setBalance(val); setBalance(val);
} }
getBalance(); getBalance();
}, []); }, []);
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex h-16 items-center justify-end px-3" className="flex h-16 items-center justify-end px-3"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-end"> <div className="text-end">
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300"> <div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
Your balance Your balance
</div> </div>
<div className="font-medium leading-tight"> <div className="font-medium leading-tight">
{value.bitcoinFormatted} {value.bitcoinFormatted}
</div> </div>
</div> </div>
<User.Provider pubkey={account}> <User.Provider pubkey={account}>
<User.Root> <User.Root>
<User.Avatar className="size-9 rounded-full" /> <User.Avatar className="size-9 rounded-full" />
</User.Root> </User.Root>
</User.Provider> </User.Provider>
</div> </div>
</div> </div>
); );
} }

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,48 +1,48 @@
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";
export function Conversation({ export function Conversation({
event, event,
className, className,
}: { }: {
event: Event; event: Event;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false }); const { ark } = useRouteContext({ strict: false });
const thread = ark.parse_event_thread(event.tags); const thread = ark.parse_event_thread(event.tags);
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root <Note.Root
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className, className,
)} )}
> >
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{thread?.root ? <Note.Child eventId={thread?.root} isRoot /> : null} {thread?.root ? <Note.Child eventId={thread?.root} isRoot /> : null}
<div className="flex items-center gap-2 px-3"> <div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400"> <div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<ThreadIcon className="size-4" /> <ThreadIcon className="size-4" />
Thread Thread
</div> </div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" /> <div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div> </div>
{thread?.reply ? <Note.Child eventId={thread?.reply} /> : null} {thread?.reply ? <Note.Child eventId={thread?.reply} /> : null}
<div> <div>
<div className="px-3 h-14 flex items-center justify-between"> <div className="px-3 h-14 flex items-center justify-between">
<Note.User /> <Note.User />
</div> </div>
<Note.Content className="px-3" /> <Note.Content className="px-3" />
</div> </div>
</div> </div>
<div className="flex items-center h-14 px-3"> <div className="flex items-center h-14 px-3">
<Note.Open /> <Note.Open />
</div> </div>
</Note.Root> </Note.Root>
</Note.Provider> </Note.Provider>
); );
} }

View File

@@ -12,16 +12,16 @@ import { NoteRoot } from "./root";
import { NoteUser } from "./user"; import { NoteUser } from "./user";
export const Note = { export const Note = {
Provider: NoteProvider, Provider: NoteProvider,
Root: NoteRoot, Root: NoteRoot,
User: NoteUser, User: NoteUser,
Menu: NoteMenu, Menu: NoteMenu,
Reply: NoteReply, Reply: NoteReply,
Repost: NoteRepost, Repost: NoteRepost,
Content: NoteContent, Content: NoteContent,
ContentLarge: NoteContentLarge, ContentLarge: NoteContentLarge,
Zap: NoteZap, Zap: NoteZap,
Open: NoteOpenThread, Open: NoteOpenThread,
Child: NoteChild, Child: NoteChild,
Activity: NoteActivity, Activity: NoteActivity,
}; };

View File

@@ -1,32 +1,32 @@
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({
event, event,
className, className,
}: { }: {
event: Event; event: Event;
className?: string; className?: string;
}) { }) {
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root <Note.Root
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className, className,
)} )}
> >
<div> <div>
<div className="px-3 h-14 flex items-center justify-between"> <div className="px-3 h-14 flex items-center justify-between">
<Note.User /> <Note.User />
</div> </div>
<Note.Content className="px-3" /> <Note.Content className="px-3" />
</div> </div>
<div className="flex items-center h-14 px-3"> <div className="flex items-center h-14 px-3">
<Note.Open /> <Note.Open />
</div> </div>
</Note.Root> </Note.Root>
</Note.Provider> </Note.Provider>
); );
} }

View File

@@ -1,47 +1,47 @@
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({
event, event,
className, className,
}: { }: {
event: Event; event: Event;
className?: string; className?: string;
}) { }) {
const quoteEventId = event.tags.find( const quoteEventId = event.tags.find(
(tag) => tag[0] === "q" || tag[3] === "mention", (tag) => tag[0] === "q" || tag[3] === "mention",
)?.[1]; )?.[1];
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root <Note.Root
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className, className,
)} )}
> >
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Note.Child eventId={quoteEventId} isRoot /> <Note.Child eventId={quoteEventId} isRoot />
<div className="flex items-center gap-2 px-3"> <div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400"> <div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<QuoteIcon className="size-4" /> <QuoteIcon className="size-4" />
Quote Quote
</div> </div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" /> <div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div> </div>
<div> <div>
<div className="px-3 h-14 flex items-center justify-between"> <div className="px-3 h-14 flex items-center justify-between">
<Note.User /> <Note.User />
</div> </div>
<Note.Content className="px-3" quote={false} clean /> <Note.Content className="px-3" quote={false} clean />
</div> </div>
</div> </div>
<div className="flex items-center h-14 px-3"> <div className="flex items-center h-14 px-3">
<Note.Open /> <Note.Open />
</div> </div>
</Note.Root> </Note.Root>
</Note.Provider> </Note.Provider>
); );
} }

View File

@@ -1,85 +1,85 @@
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";
export function RepostNote({ export function RepostNote({
event, event,
className, className,
}: { }: {
event: Event; event: Event;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false }); const { ark } = useRouteContext({ strict: false });
const { const {
isLoading, isLoading,
isError, isError,
data: repostEvent, data: repostEvent,
} = useQuery({ } = useQuery({
queryKey: ["repost", event.id], queryKey: ["repost", event.id],
queryFn: async () => { queryFn: async () => {
try { try {
if (event.content.length > 50) { if (event.content.length > 50) {
const embed: Event = JSON.parse(event.content); const embed: Event = JSON.parse(event.content);
return embed; return embed;
} }
const id = event.tags.find((el) => el[0] === "e")?.[1]; const id = event.tags.find((el) => el[0] === "e")?.[1];
const repostEvent = await ark.get_event(id); const repostEvent = await ark.get_event(id);
return repostEvent; return repostEvent;
} catch (e) { } catch (e) {
throw new Error(e); throw new Error(e);
} }
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnMount: false, refetchOnMount: false,
}); });
return ( return (
<Note.Root <Note.Root
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className, className,
)} )}
> >
<User.Provider pubkey={event.pubkey}> <User.Provider pubkey={event.pubkey}>
<User.Root className="flex items-center gap-2 px-3 py-3 border-b border-neutral-100 dark:border-neutral-800/50 rounded-t-xl"> <User.Root className="flex items-center gap-2 px-3 py-3 border-b border-neutral-100 dark:border-neutral-800/50 rounded-t-xl">
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300"> <div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Reposted by Reposted by
</div> </div>
<User.Avatar className="size-6 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" /> <User.Avatar className="size-6 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
</User.Root> </User.Root>
</User.Provider> </User.Provider>
{isLoading ? ( {isLoading ? (
<div className="flex h-20 items-center justify-center gap-2"> <div className="flex h-20 items-center justify-center gap-2">
<Spinner /> <Spinner />
Loading event... Loading event...
</div> </div>
) : isError || !repostEvent ? ( ) : isError || !repostEvent ? (
<div className="flex h-20 items-center justify-center"> <div className="flex h-20 items-center justify-center">
Event not found within your current relay set Event not found within your current relay set
</div> </div>
) : ( ) : (
<Note.Provider event={repostEvent}> <Note.Provider event={repostEvent}>
<Note.Root> <Note.Root>
<div className="px-3 h-14 flex items-center justify-between"> <div className="px-3 h-14 flex items-center justify-between">
<Note.User /> <Note.User />
<Note.Menu /> <Note.Menu />
</div> </div>
<Note.Content className="px-3" /> <Note.Content className="px-3" />
<div className="mt-3 flex items-center gap-4 h-14 px-3"> <div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open /> <Note.Open />
<Note.Reply /> <Note.Reply />
<Note.Repost /> <Note.Repost />
<Note.Zap /> <Note.Zap />
</div> </div>
</Note.Root> </Note.Root>
</Note.Provider> </Note.Provider>
)} )}
</Note.Root> </Note.Root>
); );
} }

View File

@@ -1,34 +1,34 @@
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,
className, className,
}: { }: {
event: Event; event: Event;
className?: string; className?: string;
}) { }) {
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root <Note.Root
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
className, className,
)} )}
> >
<div className="px-3 h-14 flex items-center justify-between"> <div className="px-3 h-14 flex items-center justify-between">
<Note.User /> <Note.User />
<Note.Menu /> <Note.Menu />
</div> </div>
<Note.Content className="px-3" /> <Note.Content className="px-3" />
<div className="mt-3 flex items-center gap-4 h-14 px-3"> <div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open /> <Note.Open />
<Note.Reply /> <Note.Reply />
<Note.Repost /> <Note.Repost />
<Note.Zap /> <Note.Zap />
</div> </div>
</Note.Root> </Note.Root>
</Note.Provider> </Note.Provider>
); );
} }

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,170 +13,175 @@ 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)); }
} },
}, component: Screen,
component: Screen,
}); });
function Screen() { function Screen() {
const vlistRef = useRef<VListHandle>(null); const userSavedColumns = Route.useLoaderData();
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);
const goLeft = () => { const goLeft = () => {
const prevIndex = Math.max(selectedIndex - 1, 0); const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex); setSelectedIndex(prevIndex);
vlistRef.current.scrollToIndex(prevIndex, { vlistRef.current.scrollToIndex(prevIndex, {
align: "center", align: "center",
}); });
}; };
const goRight = () => { const goRight = () => {
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1); const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
setSelectedIndex(nextIndex); setSelectedIndex(nextIndex);
vlistRef.current.scrollToIndex(nextIndex, { vlistRef.current.scrollToIndex(nextIndex, {
align: "center", align: "center",
}); });
}; };
const add = useDebouncedCallback((column: LumeColumn) => { const add = useDebouncedCallback((column: LumeColumn) => {
// update col label // update col label
column.label = `${column.label}-${nanoid()}`; column.label = `${column.label}-${nanoid()}`;
// create new cols // create new cols
const cols = [...columns]; const cols = [...columns];
const openColIndex = cols.findIndex((col) => col.label === "open"); const openColIndex = cols.findIndex((col) => col.label === "open");
const newCols = [ const newCols = [
...cols.slice(0, openColIndex), ...cols.slice(0, openColIndex),
column, column,
...cols.slice(openColIndex), ...cols.slice(openColIndex),
]; ];
setColumns(newCols); setColumns(newCols);
setSelectedIndex(newCols.length); setSelectedIndex(newCols.length);
setIsScroll(true); setIsScroll(true);
// scroll to the newest column // scroll to the newest column
vlistRef.current.scrollToIndex(newCols.length - 1, { vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "end", align: "end",
}); });
}, 150); }, 150);
const remove = useDebouncedCallback((label: string) => { const remove = useDebouncedCallback((label: string) => {
const newCols = columns.filter((t) => t.label !== label); const newCols = columns.filter((t) => t.label !== label);
setColumns(newCols); setColumns(newCols);
setSelectedIndex(newCols.length); setSelectedIndex(newCols.length);
setIsScroll(true); setIsScroll(true);
// scroll to the first column // scroll to the first column
vlistRef.current.scrollToIndex(newCols.length - 1, { vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "start", align: "start",
}); });
}, 150); }, 150);
const updateName = useDebouncedCallback((label: string, title: string) => { const updateName = useDebouncedCallback((label: string, title: string) => {
const currentColIndex = columns.findIndex((col) => col.label === label); const currentColIndex = columns.findIndex((col) => col.label === label);
const updatedCol = Object.assign({}, columns[currentColIndex]); const updatedCol = Object.assign({}, columns[currentColIndex]);
updatedCol.name = title; updatedCol.name = title;
const newCols = columns.slice(); const newCols = columns.slice();
newCols[currentColIndex] = updatedCol; newCols[currentColIndex] = updatedCol;
setColumns(newCols); setColumns(newCols);
}, 150); }, 150);
const startResize = useDebouncedCallback( const startResize = useDebouncedCallback(
() => setIsResize((prev) => !prev), () => setIsResize((prev) => !prev),
150, 150,
); );
useEffect(() => { useEffect(() => {
// save state setColumns(userSavedColumns);
ark.set_columns(columns); }, [userSavedColumns]);
}, [columns]);
useEffect(() => { useEffect(() => {
const unlistenColEvent = listen<EventColumns>("columns", (data) => { // save state
if (data.payload.type === "add") add(data.payload.column); ark.set_columns(columns);
if (data.payload.type === "remove") remove(data.payload.label); }, [columns]);
if (data.payload.type === "set_title")
updateName(data.payload.label, data.payload.title);
});
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => { useEffect(() => {
startResize(); const unlistenColEvent = listen<EventColumns>("columns", (data) => {
}); if (data.payload.type === "add") add(data.payload.column);
if (data.payload.type === "remove") remove(data.payload.label);
if (data.payload.type === "set_title")
updateName(data.payload.label, data.payload.title);
});
return () => { const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
unlistenColEvent.then((f) => f()); startResize();
unlistenWindowResize.then((f) => f()); });
};
}, []);
return ( return () => {
<div className="h-full w-full"> unlistenColEvent.then((f) => f());
<VList unlistenWindowResize.then((f) => f());
ref={vlistRef} };
horizontal }, []);
tabIndex={-1}
itemSize={440} return (
overscan={3} <div className="h-full w-full">
onScroll={() => setIsScroll(true)} <VList
onScrollEnd={() => setIsScroll(false)} ref={vlistRef}
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none" horizontal
> tabIndex={-1}
{columns.map((column) => ( itemSize={440}
<Col overscan={3}
key={column.label} onScroll={() => setIsScroll(true)}
column={column} onScrollEnd={() => setIsScroll(false)}
account={account} className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
isScroll={isScroll} cache={null}
isResize={isResize} >
/> {columns.map((column) => (
))} <Column
</VList> key={column.label}
<Toolbar> column={column}
<div className="flex items-center gap-1"> account={account}
<button isScroll={isScroll}
type="button" isResize={isResize}
onClick={() => goLeft()} />
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10" ))}
> </VList>
<ArrowLeftIcon className="size-5" /> <Toolbar>
</button> <div className="flex items-center gap-1">
<button <button
type="button" type="button"
onClick={() => goRight()} onClick={() => goLeft()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10" className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
> >
<ArrowRightIcon className="size-5" /> <ArrowLeftIcon className="size-5" />
</button> </button>
</div> <button
</Toolbar> type="button"
</div> onClick={() => goRight()}
); className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowRightIcon className="size-5" />
</button>
</div>
</Toolbar>
</div>
);
} }

View File

@@ -2,177 +2,183 @@ import { BellIcon, ComposeFilledIcon, PlusIcon, SearchIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types"; import { Event, Kind } from "@lume/types";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { import {
cn, cn,
decodeZapInvoice, decodeZapInvoice,
displayNpub, displayNpub,
sendNativeNotification, sendNativeNotification,
} from "@lume/utils"; } from "@lume/utils";
import { Outlet, createFileRoute } from "@tanstack/react-router"; 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 }) => {
const ark = context.ark; const ark = context.ark;
const accounts = await ark.get_all_accounts(); const accounts = await ark.get_all_accounts();
return { accounts }; return { accounts };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark, platform } = Route.useRouteContext(); const { ark, platform } = Route.useRouteContext();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
return ( return (
<div className="flex h-screen w-screen flex-col"> <div className="flex h-screen w-screen flex-col">
<div <div
data-tauri-drag-region data-tauri-drag-region
className={cn( className={cn(
"flex h-11 shrink-0 items-center justify-between pr-2", "flex h-11 shrink-0 items-center justify-between pr-2",
platform === "macos" ? "ml-2 pl-20" : "pl-4", platform === "macos" ? "ml-2 pl-20" : "pl-4",
)} )}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Accounts /> <Accounts />
<button <button
type="button" type="button"
onClick={() => navigate({ to: "/landing" })} onClick={() => navigate({ to: "/landing" })}
className="inline-flex size-8 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20" className="inline-flex size-8 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
> >
<PlusIcon className="size-5" /> <PlusIcon className="size-5" />
</button> </button>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
onClick={() => ark.open_editor()} onClick={() => ark.open_editor()}
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600" className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
> >
<ComposeFilledIcon className="size-4" /> <ComposeFilledIcon className="size-4" />
New Post New Post
</button> </button>
<Bell /> <Bell />
<button <button
type="button" type="button"
onClick={() => ark.open_search()} onClick={() => ark.open_search()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10" className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
> >
<SearchIcon className="size-5" /> <SearchIcon className="size-5" />
</button> </button>
<div id="toolbar" /> <div id="toolbar" />
</div> </div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
); );
} }
function Accounts() { function Accounts() {
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const { ark, accounts } = Route.useRouteContext(); const { ark, accounts } = Route.useRouteContext();
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);
}
const select = await ark.load_selected_account(npub); // change current account and update signer
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.");
}
};
return ( return (
<div data-tauri-drag-region className="flex items-center gap-3"> <div data-tauri-drag-region className="flex items-center gap-3">
{accounts.map((user) => ( {accounts.map((user) => (
<button key={user} type="button" onClick={() => changeAccount(user)}> <button key={user} type="button" onClick={() => changeAccount(user)}>
<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"
: "", : "",
)} )}
> >
<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",
)} )}
/> />
</User.Root> </User.Root>
</User.Provider> </User.Provider>
</button> </button>
))} ))}
</div> </div>
); );
} }
function Bell() { function Bell() {
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
const { account } = Route.useParams(); const { account } = Route.useParams();
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
useEffect(() => { useEffect(() => {
const unlisten = getCurrent().listen<string>( const unlisten = getCurrent().listen<string>(
"activity", "activity",
async (payload) => { async (payload) => {
setCount((prevCount) => prevCount + 1); setCount((prevCount) => prevCount + 1);
await invoke("set_badge", { count }); await invoke("set_badge", { count });
const event: Event = JSON.parse(payload.payload); const event: Event = JSON.parse(payload.payload);
const user = await ark.get_profile(event.pubkey); const user = await ark.get_profile(event.pubkey);
const userName = const userName =
user.display_name || user.name || displayNpub(event.pubkey, 16); user.display_name || user.name || displayNpub(event.pubkey, 16);
switch (event.kind) { switch (event.kind) {
case Kind.Text: { case Kind.Text: {
sendNativeNotification("Mentioned you in a note", userName); sendNativeNotification("Mentioned you in a note", userName);
break; break;
} }
case Kind.Repost: { case Kind.Repost: {
sendNativeNotification("Reposted your note", userName); sendNativeNotification("Reposted your note", userName);
break; break;
} }
case Kind.ZapReceipt: { case Kind.ZapReceipt: {
const amount = decodeZapInvoice(event.tags); const amount = decodeZapInvoice(event.tags);
sendNativeNotification( sendNativeNotification(
`Zapped ₿ ${amount.bitcoinFormatted}`, `Zapped ₿ ${amount.bitcoinFormatted}`,
userName, userName,
); );
break; break;
} }
default: default:
break; break;
} }
}, },
); );
return () => { return () => {
unlisten.then((f) => f()); unlisten.then((f) => f());
}; };
}, []); }, []);
return ( return (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setCount(0); setCount(0);
ark.open_activity(account); ark.open_activity(account);
}} }}
className="relative inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10" className="relative inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
> >
<BellIcon className="size-5" /> <BellIcon className="size-5" />
{count > 0 ? ( {count > 0 ? (
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" /> <span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
) : null} ) : null}
</button> </button>
); );
} }

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();
let filter = Filter::new()
.author(public_key)
.kind(Kind::ApplicationSpecificData)
.identifier(key)
.limit(1);
if let Ok(author) = public_key { match client
let filter = Filter::new() .get_events_of(vec![filter], Some(Duration::from_secs(5)))
.author(author) .await
.kind(Kind::ApplicationSpecificData) {
.identifier(key) Ok(events) => {
.limit(1);
let query = client
.get_events_of(vec![filter], Some(Duration::from_secs(10)))
.await;
if let Ok(events) = query {
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())