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:
雨宮蓮
2024-10-22 16:00:06 +07:00
committed by GitHub
parent ba9c81a10a
commit cc7de41bfd
89 changed files with 2695 additions and 2911 deletions
+7 -3
View File
@@ -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,
};
+2 -2
View File
@@ -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("#", ""));
+179 -92
View File
@@ -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/newsfeed")({
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/stories")({
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;
+1 -1
View File
@@ -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);