feat: update ui and add compose dialog
This commit is contained in:
@@ -47,6 +47,14 @@ try {
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getContactList() : Promise<Result<string[], null>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_contact_list") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getChats() : Promise<Result<string[], string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_chats") };
|
||||
|
||||
@@ -87,3 +87,10 @@ export function groupEventByDate(events: NostrEvent[]) {
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function isEmojiOnly(str: string) {
|
||||
const stringToTest = str.replace(/ /g, "");
|
||||
const emojiRegex =
|
||||
/^(?:(?:\p{RI}\p{RI}|\p{Emoji}(?:\p{Emoji_Modifier}|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?(?:\u{200D}\p{Emoji}(?:\p{Emoji_Modifier}|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?)*)|[\u{1f900}-\u{1f9ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}])+$/u;
|
||||
return emojiRegex.test(stringToTest) && Number.isNaN(Number(stringToTest));
|
||||
}
|
||||
|
||||
@@ -24,18 +24,20 @@ export function UserAvatar({ className }: { className?: string }) {
|
||||
>
|
||||
{!user.isLoading ? (
|
||||
<>
|
||||
<Avatar.Image
|
||||
src={`//wsrv.nl/?url=${user.profile?.picture}&w=200&h=200`}
|
||||
alt={user.pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
|
||||
/>
|
||||
{user.profile?.picture ? (
|
||||
<Avatar.Image
|
||||
src={`//wsrv.nl/?url=${user.profile?.picture}&w=200&h=200`}
|
||||
alt={user.pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full aspect-square object-cover outline-[.5px] outline-black/15"
|
||||
/>
|
||||
) : null}
|
||||
<Avatar.Fallback>
|
||||
<img
|
||||
src={fallback}
|
||||
alt={user.pubkey}
|
||||
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
|
||||
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as AccountContactsImport } from './routes/$account.contacts'
|
||||
import { Route as AccountChatsIdImport } from './routes/$account.chats.$id'
|
||||
|
||||
// Create Virtual Routes
|
||||
@@ -22,7 +23,6 @@ const NostrConnectLazyImport = createFileRoute('/nostr-connect')()
|
||||
const NewLazyImport = createFileRoute('/new')()
|
||||
const ImportKeyLazyImport = createFileRoute('/import-key')()
|
||||
const CreateAccountLazyImport = createFileRoute('/create-account')()
|
||||
const ContactsLazyImport = createFileRoute('/contacts')()
|
||||
const AccountChatsLazyImport = createFileRoute('/$account/chats')()
|
||||
const AccountChatsNewLazyImport = createFileRoute('/$account/chats/new')()
|
||||
|
||||
@@ -50,11 +50,6 @@ const CreateAccountLazyRoute = CreateAccountLazyImport.update({
|
||||
import('./routes/create-account.lazy').then((d) => d.Route),
|
||||
)
|
||||
|
||||
const ContactsLazyRoute = ContactsLazyImport.update({
|
||||
path: '/contacts',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any).lazy(() => import('./routes/contacts.lazy').then((d) => d.Route))
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
@@ -67,6 +62,13 @@ const AccountChatsLazyRoute = AccountChatsLazyImport.update({
|
||||
import('./routes/$account.chats.lazy').then((d) => d.Route),
|
||||
)
|
||||
|
||||
const AccountContactsRoute = AccountContactsImport.update({
|
||||
path: '/$account/contacts',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/$account.contacts.lazy').then((d) => d.Route),
|
||||
)
|
||||
|
||||
const AccountChatsNewLazyRoute = AccountChatsNewLazyImport.update({
|
||||
path: '/new',
|
||||
getParentRoute: () => AccountChatsLazyRoute,
|
||||
@@ -77,7 +79,9 @@ const AccountChatsNewLazyRoute = AccountChatsNewLazyImport.update({
|
||||
const AccountChatsIdRoute = AccountChatsIdImport.update({
|
||||
path: '/$id',
|
||||
getParentRoute: () => AccountChatsLazyRoute,
|
||||
} as any)
|
||||
} as any).lazy(() =>
|
||||
import('./routes/$account.chats.$id.lazy').then((d) => d.Route),
|
||||
)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
@@ -90,13 +94,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/contacts': {
|
||||
id: '/contacts'
|
||||
path: '/contacts'
|
||||
fullPath: '/contacts'
|
||||
preLoaderRoute: typeof ContactsLazyImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/create-account': {
|
||||
id: '/create-account'
|
||||
path: '/create-account'
|
||||
@@ -125,6 +122,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof NostrConnectLazyImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/$account/contacts': {
|
||||
id: '/$account/contacts'
|
||||
path: '/$account/contacts'
|
||||
fullPath: '/$account/contacts'
|
||||
preLoaderRoute: typeof AccountContactsImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/$account/chats': {
|
||||
id: '/$account/chats'
|
||||
path: '/$account/chats'
|
||||
@@ -153,11 +157,11 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
export const routeTree = rootRoute.addChildren({
|
||||
IndexRoute,
|
||||
ContactsLazyRoute,
|
||||
CreateAccountLazyRoute,
|
||||
ImportKeyLazyRoute,
|
||||
NewLazyRoute,
|
||||
NostrConnectLazyRoute,
|
||||
AccountContactsRoute,
|
||||
AccountChatsLazyRoute: AccountChatsLazyRoute.addChildren({
|
||||
AccountChatsIdRoute,
|
||||
AccountChatsNewLazyRoute,
|
||||
@@ -173,20 +177,17 @@ export const routeTree = rootRoute.addChildren({
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/contacts",
|
||||
"/create-account",
|
||||
"/import-key",
|
||||
"/new",
|
||||
"/nostr-connect",
|
||||
"/$account/contacts",
|
||||
"/$account/chats"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/contacts": {
|
||||
"filePath": "contacts.lazy.tsx"
|
||||
},
|
||||
"/create-account": {
|
||||
"filePath": "create-account.lazy.tsx"
|
||||
},
|
||||
@@ -199,6 +200,9 @@ export const routeTree = rootRoute.addChildren({
|
||||
"/nostr-connect": {
|
||||
"filePath": "nostr-connect.lazy.tsx"
|
||||
},
|
||||
"/$account/contacts": {
|
||||
"filePath": "$account.contacts.tsx"
|
||||
},
|
||||
"/$account/chats": {
|
||||
"filePath": "$account.chats.lazy.tsx",
|
||||
"children": [
|
||||
|
||||
288
src/routes/$account.chats.$id.lazy.tsx
Normal file
288
src/routes/$account.chats.$id.lazy.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { commands } from "@/commands";
|
||||
import { cn, getReceivers, groupEventByDate, time } from "@/commons";
|
||||
import { Spinner } from "@/components/spinner";
|
||||
import { User } from "@/components/user";
|
||||
import { ArrowUp, Paperclip } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import { useCallback, useRef, useState, useTransition } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
type Payload = {
|
||||
event: string;
|
||||
sender: string;
|
||||
};
|
||||
|
||||
export const Route = createLazyFileRoute("/$account/chats/$id")({
|
||||
component: Screen,
|
||||
pendingComponent: Pending,
|
||||
});
|
||||
|
||||
function Pending() {
|
||||
return (
|
||||
<div className="size-full flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Screen() {
|
||||
return (
|
||||
<div className="size-full flex flex-col">
|
||||
<Header />
|
||||
<List />
|
||||
<Form />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const { account, id } = Route.useParams();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-12 shrink-0 flex items-center justify-between px-3.5 border-b border-neutral-100 dark:border-neutral-800"
|
||||
>
|
||||
<div>
|
||||
<div className="flex -space-x-1 overflow-hidden">
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root className="size-8 rounded-full inline-block ring-2 ring-white dark:ring-neutral-900">
|
||||
<User.Avatar className="size-8 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<User.Provider pubkey={id}>
|
||||
<User.Root className="size-8 rounded-full inline-block ring-2 ring-white dark:ring-neutral-900">
|
||||
<User.Avatar className="size-8 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-7 inline-flex items-center justify-center gap-1.5 px-2 rounded-full bg-neutral-100 dark:bg-neutral-900">
|
||||
<span className="relative flex size-2">
|
||||
<span className="animate-ping absolute inline-flex size-full rounded-full bg-teal-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full size-2 bg-teal-500" />
|
||||
</span>
|
||||
<div className="text-xs leading-tight">Connected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function List() {
|
||||
const { account, id } = Route.useParams();
|
||||
const { isLoading, isError, data } = useQuery({
|
||||
queryKey: ["chats", id],
|
||||
queryFn: async () => {
|
||||
const res = await commands.getChatMessages(id);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const raw = res.data;
|
||||
const events: NostrEvent[] = raw.map((item) => JSON.parse(item));
|
||||
|
||||
return events;
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
},
|
||||
select: (data) => {
|
||||
const groups = groupEventByDate(data);
|
||||
return Object.entries(groups).reverse();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(item: NostrEvent, idx: number) => {
|
||||
const self = account === item.pubkey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx + item.id}
|
||||
className="flex items-center justify-between gap-3 my-1.5 px-3 border-l-2 border-transparent hover:border-blue-400"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-w-0 inline-flex",
|
||||
self ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"py-2 px-3 w-fit max-w-[400px] text-pretty break-message",
|
||||
!self
|
||||
? "bg-neutral-100 dark:bg-neutral-800 rounded-tl-3xl rounded-tr-3xl rounded-br-3xl rounded-bl-md"
|
||||
: "bg-blue-500 text-white rounded-tl-3xl rounded-tr-3xl rounded-br-md rounded-bl-3xl",
|
||||
)}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 w-16 flex items-center justify-end">
|
||||
<span className="text-xs text-right text-neutral-600 dark:text-neutral-400">
|
||||
{time(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen<Payload>("event", async (data) => {
|
||||
const event: NostrEvent = JSON.parse(data.payload.event);
|
||||
const sender = data.payload.sender;
|
||||
const receivers = getReceivers(event.tags);
|
||||
const group = [account, id];
|
||||
|
||||
if (!group.includes(sender)) return;
|
||||
if (!group.some((item) => receivers.includes(item))) return;
|
||||
|
||||
await queryClient.setQueryData(
|
||||
["chats", id],
|
||||
(prevEvents: NostrEvent[]) => {
|
||||
if (!prevEvents) return prevEvents;
|
||||
return [...prevEvents, event];
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden flex-1 w-full"
|
||||
>
|
||||
<ScrollArea.Viewport
|
||||
ref={ref}
|
||||
className="relative h-full py-2 [&>div]:!flex [&>div]:flex-col [&>div]:justify-end [&>div]:min-h-full"
|
||||
>
|
||||
<Virtualizer scrollRef={ref} shift>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 my-1.5 px-3">
|
||||
<div className="flex-1 min-w-0 inline-flex">
|
||||
<div className="w-44 h-[35px] py-2 max-w-[400px] bg-neutral-100 dark:bg-neutral-800 animate-pulse rounded-tl-3xl rounded-tr-3xl rounded-br-3xl rounded-bl-md" />
|
||||
</div>
|
||||
<div className="shrink-0 w-16 flex items-center justify-end" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 my-1.5 px-3">
|
||||
<div className="flex-1 min-w-0 inline-flex justify-end">
|
||||
<div className="w-44 h-[35px] py-2 max-w-[400px] bg-blue-500 text-white animate-pulse rounded-tl-3xl rounded-tr-3xl rounded-br-md rounded-bl-3xl" />
|
||||
</div>
|
||||
<div className="shrink-0 w-16 flex items-center justify-end" />
|
||||
</div>
|
||||
</>
|
||||
) : isError ? (
|
||||
<div className="w-full h-56 flex items-center justify-center">
|
||||
<div className="text-sm flex items-center gap-1.5">
|
||||
Cannot load message. Please try again later.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
<div
|
||||
key={item[0]}
|
||||
className="w-full flex flex-col items-center mt-3 gap-3"
|
||||
>
|
||||
<div className="text-xs text-center text-neutral-600 dark:text-neutral-400">
|
||||
{item[0]}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{item[1]
|
||||
.sort((a, b) => a.created_at - b.created_at)
|
||||
.map((item, idx) => renderItem(item, idx))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/40 dark:bg-white/40 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Form() {
|
||||
const { id } = Route.useParams();
|
||||
const { inbox } = Route.useRouteContext();
|
||||
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const submit = async () => {
|
||||
startTransition(async () => {
|
||||
if (!newMessage.length) return;
|
||||
|
||||
const res = await commands.sendMessage(id, newMessage);
|
||||
|
||||
if (res.status === "error") {
|
||||
await message(res.error, { title: "Coop", kind: "error" });
|
||||
return;
|
||||
}
|
||||
|
||||
setNewMessage("");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-12 shrink-0 flex items-center justify-center px-3.5">
|
||||
{!inbox.length ? (
|
||||
<div className="text-xs">
|
||||
This user doesn't have inbox relays. You cannot send messages to them.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="inline-flex gap-1">
|
||||
<div
|
||||
title="Attach media"
|
||||
className="size-9 inline-flex items-center justify-center hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-full"
|
||||
>
|
||||
<Paperclip className="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
placeholder="Message..."
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
}}
|
||||
className="flex-1 h-9 rounded-full px-3.5 bg-transparent border border-neutral-200 dark:border-neutral-800 focus:outline-none focus:border-blue-500 placeholder:text-neutral-400 dark:placeholder:text-neutral-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title="Send message"
|
||||
disabled={isPending}
|
||||
onClick={() => submit()}
|
||||
className="rounded-full size-9 inline-flex items-center justify-center bg-blue-300 hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-800 text-white"
|
||||
>
|
||||
{isPending ? <Spinner /> : <ArrowUp className="size-5" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,293 +1,9 @@
|
||||
import { commands } from "@/commands";
|
||||
import { cn, getReceivers, groupEventByDate, time } from "@/commons";
|
||||
import { Spinner } from "@/components/spinner";
|
||||
import { User } from "@/components/user";
|
||||
import { ArrowUp, Paperclip } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import { useCallback, useRef, useState, useTransition } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
type Payload = {
|
||||
event: string;
|
||||
sender: string;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/$account/chats/$id")({
|
||||
beforeLoad: async ({ params }) => {
|
||||
const inbox: string[] = await invoke("connect_inbox", { id: params.id });
|
||||
return { inbox };
|
||||
},
|
||||
component: Screen,
|
||||
pendingComponent: Pending,
|
||||
});
|
||||
|
||||
function Pending() {
|
||||
return (
|
||||
<div className="size-full flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Screen() {
|
||||
return (
|
||||
<div className="size-full flex flex-col">
|
||||
<Header />
|
||||
<List />
|
||||
<Form />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const { account, id } = Route.useParams();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-12 shrink-0 flex items-center justify-between px-3.5 border-b border-neutral-100 dark:border-neutral-800"
|
||||
>
|
||||
<div>
|
||||
<div className="flex -space-x-1 overflow-hidden">
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root className="size-7 rounded-full inline-block ring-2 ring-white dark:ring-neutral-900">
|
||||
<User.Avatar className="size-7 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<User.Provider pubkey={id}>
|
||||
<User.Root className="size-7 rounded-full inline-block ring-2 ring-white dark:ring-neutral-900">
|
||||
<User.Avatar className="size-7 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-7 inline-flex items-center justify-center gap-1.5 px-2 rounded-full bg-neutral-100 dark:bg-neutral-900">
|
||||
<span className="relative flex size-2">
|
||||
<span className="animate-ping absolute inline-flex size-full rounded-full bg-teal-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full size-2 bg-teal-500" />
|
||||
</span>
|
||||
<div className="text-xs leading-tight">Connected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function List() {
|
||||
const { account, id } = Route.useParams();
|
||||
const { isLoading, isError, data } = useQuery({
|
||||
queryKey: ["chats", id],
|
||||
queryFn: async () => {
|
||||
const res = await commands.getChatMessages(id);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const raw = res.data;
|
||||
const events: NostrEvent[] = raw.map((item) => JSON.parse(item));
|
||||
|
||||
return events;
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
},
|
||||
select: (data) => {
|
||||
const groups = groupEventByDate(data);
|
||||
return Object.entries(groups).reverse();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(item: NostrEvent, idx: number) => {
|
||||
const self = account === item.pubkey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx + item.id}
|
||||
className="flex items-center justify-between gap-3 my-1.5 px-3 border-l-2 border-transparent hover:border-blue-400"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-w-0 inline-flex",
|
||||
self ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"py-2 px-3 w-fit max-w-[400px] text-pretty break-message",
|
||||
!self
|
||||
? "bg-neutral-100 dark:bg-neutral-800 rounded-tl-3xl rounded-tr-3xl rounded-br-3xl rounded-bl-md"
|
||||
: "bg-blue-500 text-white rounded-tl-3xl rounded-tr-3xl rounded-br-md rounded-bl-3xl",
|
||||
)}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 w-16 flex items-center justify-end">
|
||||
<span className="text-xs text-right text-neutral-600 dark:text-neutral-400">
|
||||
{time(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen<Payload>("event", async (data) => {
|
||||
const event: NostrEvent = JSON.parse(data.payload.event);
|
||||
const sender = data.payload.sender;
|
||||
const receivers = getReceivers(event.tags);
|
||||
const group = [account, id];
|
||||
|
||||
if (!group.includes(sender)) return;
|
||||
if (!group.some((item) => receivers.includes(item))) return;
|
||||
|
||||
await queryClient.setQueryData(
|
||||
["chats", id],
|
||||
(prevEvents: NostrEvent[]) => {
|
||||
if (!prevEvents) return prevEvents;
|
||||
return [...prevEvents, event];
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden flex-1 w-full"
|
||||
>
|
||||
<ScrollArea.Viewport
|
||||
ref={ref}
|
||||
className="relative h-full py-2 [&>div]:!flex [&>div]:flex-col [&>div]:justify-end [&>div]:min-h-full"
|
||||
>
|
||||
<Virtualizer scrollRef={ref} shift>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 my-1.5 px-3">
|
||||
<div className="flex-1 min-w-0 inline-flex">
|
||||
<div className="w-44 h-[35px] py-2 max-w-[400px] bg-neutral-100 dark:bg-neutral-800 animate-pulse rounded-tl-3xl rounded-tr-3xl rounded-br-3xl rounded-bl-md" />
|
||||
</div>
|
||||
<div className="shrink-0 w-16 flex items-center justify-end" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 my-1.5 px-3">
|
||||
<div className="flex-1 min-w-0 inline-flex justify-end">
|
||||
<div className="w-44 h-[35px] py-2 max-w-[400px] bg-blue-500 text-white animate-pulse rounded-tl-3xl rounded-tr-3xl rounded-br-md rounded-bl-3xl" />
|
||||
</div>
|
||||
<div className="shrink-0 w-16 flex items-center justify-end" />
|
||||
</div>
|
||||
</>
|
||||
) : isError ? (
|
||||
<div className="w-full h-56 flex items-center justify-center">
|
||||
<div className="flex items-center gap-1.5">
|
||||
Cannot load message. Please try again later.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
<div
|
||||
key={item[0]}
|
||||
className="w-full flex flex-col items-center mt-3 gap-3"
|
||||
>
|
||||
<div className="text-xs text-center text-neutral-600 dark:text-neutral-400">
|
||||
{item[0]}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{item[1]
|
||||
.sort((a, b) => a.created_at - b.created_at)
|
||||
.map((item, idx) => renderItem(item, idx))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/40 dark:bg-white/40 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Form() {
|
||||
const { id } = Route.useParams();
|
||||
const { inbox } = Route.useRouteContext();
|
||||
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const submit = async () => {
|
||||
startTransition(async () => {
|
||||
if (!newMessage.length) return;
|
||||
|
||||
const res = await commands.sendMessage(id, newMessage);
|
||||
|
||||
if (res.status === "error") {
|
||||
await message(res.error, { title: "Coop", kind: "error" });
|
||||
return;
|
||||
}
|
||||
|
||||
setNewMessage("");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-12 shrink-0 flex items-center justify-center px-3.5">
|
||||
{!inbox.length ? (
|
||||
<div className="text-xs">
|
||||
This user doesn't have inbox relays. You cannot send messages to them.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="inline-flex gap-1">
|
||||
<div
|
||||
title="Attach media"
|
||||
className="size-9 inline-flex items-center justify-center hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-full"
|
||||
>
|
||||
<Paperclip className="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
placeholder="Message..."
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
}}
|
||||
className="flex-1 h-9 rounded-full px-3.5 bg-transparent border border-neutral-200 dark:border-neutral-800 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title="Send message"
|
||||
disabled={isPending}
|
||||
onClick={() => submit()}
|
||||
className="rounded-full size-9 inline-flex items-center justify-center bg-blue-300 hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-800 text-white"
|
||||
>
|
||||
{isPending ? <Spinner /> : <ArrowUp className="size-5" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { commands } from "@/commands";
|
||||
import { ago, cn } from "@/commons";
|
||||
import { Spinner } from "@/components/spinner";
|
||||
import { User } from "@/components/user";
|
||||
import { DotsThree, Plus, UsersThree } from "@phosphor-icons/react";
|
||||
import { ArrowRight, CirclesFour, Plus, X } from "@phosphor-icons/react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
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 { message } from "@tauri-apps/plugin-dialog";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState, useTransition } from "react";
|
||||
|
||||
type Payload = {
|
||||
event: string;
|
||||
@@ -28,7 +31,6 @@ function Screen() {
|
||||
>
|
||||
<Header />
|
||||
<ChatList />
|
||||
<CurrentUser />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 min-h-0 bg-white dark:bg-neutral-900 overflow-auto">
|
||||
<Outlet />
|
||||
@@ -38,24 +40,27 @@ function Screen() {
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const { platform } = Route.useRouteContext();
|
||||
const { account } = Route.useParams();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="shrink-0 h-12 px-3.5 flex items-center justify-end"
|
||||
className={cn(
|
||||
"shrink-0 h-12 flex items-center justify-between",
|
||||
platform === "macos" ? "pl-24 pr-3.5" : "px-3.5",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<CurrentUser />
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link
|
||||
to="/contacts"
|
||||
className="size-7 rounded-lg inline-flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
to="/$account/contacts"
|
||||
params={{ account }}
|
||||
className="size-8 rounded-full inline-flex items-center justify-center bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<UsersThree className="size-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/new"
|
||||
className="h-7 w-12 rounded-t-lg rounded-l-lg rounded-r inline-flex items-center justify-center bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
<CirclesFour className="size-4" />
|
||||
</Link>
|
||||
<Compose />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -83,7 +88,7 @@ function ChatList() {
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen("synchronized", async () => {
|
||||
await queryClient.refetchQueries({ queryKey: ["chats"] })
|
||||
await queryClient.refetchQueries({ queryKey: ["chats"] });
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -193,6 +198,146 @@ function ChatList() {
|
||||
);
|
||||
}
|
||||
|
||||
function Compose() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [target, setTarget] = useState("");
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { account } = Route.useParams();
|
||||
const { isLoading, data: contacts } = useQuery({
|
||||
queryKey: ["contacts", account],
|
||||
queryFn: async () => {
|
||||
const res = await commands.getContactList();
|
||||
|
||||
if (res.status === "ok") {
|
||||
return res.data;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const sendMessage = async () => {
|
||||
startTransition(async () => {
|
||||
if (!newMessage.length) return;
|
||||
if (!target.length) return;
|
||||
|
||||
const res = await commands.sendMessage(target, newMessage);
|
||||
|
||||
if (res.status === "ok") {
|
||||
navigate({
|
||||
to: "/$account/chats/$id",
|
||||
params: { account, id: target },
|
||||
});
|
||||
} else {
|
||||
await message(res.error, { title: "Coop", kind: "error" });
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<Plus className="size-4" weight="bold" />
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="bg-black/20 dark:bg-white/20 data-[state=open]:animate-overlay fixed inset-0" />
|
||||
<Dialog.Content className="flex flex-col data-[state=open]:animate-content fixed top-[50%] left-[50%] w-full h-full max-h-[500px] max-w-[400px] translate-x-[-50%] translate-y-[-50%] rounded-xl bg-white dark:bg-neutral-900 shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] focus:outline-none">
|
||||
<div className="h-28 shrink-0 flex flex-col justify-end">
|
||||
<div className="h-10 inline-flex items-center justify-between px-3.5 text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Send to
|
||||
<Dialog.Close asChild>
|
||||
<button type="button">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-3.5 border-b border-neutral-100 dark:border-neutral-800">
|
||||
<span className="shrink-0 font-medium">To:</span>
|
||||
<input
|
||||
placeholder="npub1..."
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
disabled={isPending || isLoading}
|
||||
className="flex-1 h-9 bg-transparent focus:outline-none placeholder:text-neutral-400 dark:placeholder:text-neutral-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-3.5 border-b border-neutral-100 dark:border-neutral-800">
|
||||
<span className="shrink-0 font-medium">Message:</span>
|
||||
<input
|
||||
placeholder="hello..."
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
disabled={isPending || isLoading}
|
||||
className="flex-1 h-9 bg-transparent focus:outline-none placeholder:text-neutral-400 dark:placeholder:text-neutral-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending || isLoading || !newMessage.length}
|
||||
onClick={() => sendMessage()}
|
||||
className="rounded-full size-7 inline-flex items-center justify-center bg-blue-300 hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-800 text-white"
|
||||
>
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden flex-1 size-full"
|
||||
>
|
||||
<ScrollArea.Viewport className="relative h-full p-2">
|
||||
{isLoading ? (
|
||||
<div className="h-[400px] flex items-center justify-center">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
) : !contacts?.length ? (
|
||||
<div className="h-[400px] flex items-center justify-center">
|
||||
<p className="text-sm">Contact is empty.</p>
|
||||
</div>
|
||||
) : (
|
||||
contacts?.map((contact) => (
|
||||
<button
|
||||
key={contact}
|
||||
type="button"
|
||||
onClick={() => setTarget(contact)}
|
||||
className="block w-full p-2 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<User.Provider pubkey={contact}>
|
||||
<User.Root className="flex items-center gap-2">
|
||||
<User.Avatar className="size-10 rounded-full" />
|
||||
<User.Name className="font-medium" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/40 dark:bg-white/40 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentUser() {
|
||||
const params = Route.useParams();
|
||||
const navigate = Route.useNavigate();
|
||||
@@ -201,6 +346,14 @@ function CurrentUser() {
|
||||
e.preventDefault();
|
||||
|
||||
const menuItems = await Promise.all([
|
||||
MenuItem.new({
|
||||
text: "Contacts",
|
||||
action: () =>
|
||||
navigate({
|
||||
to: "/$account/contacts",
|
||||
params: { account: params.account },
|
||||
}),
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Settings",
|
||||
action: () => navigate({ to: "/" }),
|
||||
@@ -224,20 +377,16 @@ function CurrentUser() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="shrink-0 h-12 flex items-center justify-between px-3.5 border-t border-black/5 dark:border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className="shrink-0 size-8 flex items-center justify-center rounded-full ring-1 ring-teal-500"
|
||||
>
|
||||
<User.Provider pubkey={params.account}>
|
||||
<User.Root className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-8 rounded-full" />
|
||||
<User.Name className="text-sm font-medium leading-tight" />
|
||||
<User.Root className="shrink-0">
|
||||
<User.Avatar className="size-7 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className="size-7 inline-flex items-center justify-center rounded-md text-neutral-700 dark:text-neutral-300 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<DotsThree className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
63
src/routes/$account.contacts.lazy.tsx
Normal file
63
src/routes/$account.contacts.lazy.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { User } from "@/components/user";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Link, createLazyFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createLazyFileRoute("/$account/contacts")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const params = Route.useParams();
|
||||
const contacts = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full flex flex-col"
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-12 shrink-0 flex items-center justify-between px-3.5"
|
||||
>
|
||||
<div />
|
||||
<div className="text-sm font-semibold uppercase">Contact List</div>
|
||||
<div className="inline-flex items-center justify-end">
|
||||
<Link
|
||||
to="/$account/chats/new"
|
||||
params={{ account: params.account }}
|
||||
className="size-7 inline-flex items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea.Viewport className="relative h-full flex-1 px-3.5 pb-3.5">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{contacts.map((contact) => (
|
||||
<Link
|
||||
key={contact}
|
||||
to="/$account/chats/$id"
|
||||
params={{ account: params.account, id: contact }}
|
||||
>
|
||||
<User.Provider key={contact} pubkey={contact}>
|
||||
<User.Root className="h-44 flex flex-col items-center justify-center gap-3 p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5">
|
||||
<User.Avatar className="size-16 rounded-full" />
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/40 dark:bg-white/40 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
14
src/routes/$account.contacts.tsx
Normal file
14
src/routes/$account.contacts.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { commands } from "@/commands";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/$account/contacts")({
|
||||
loader: async () => {
|
||||
const res = await commands.getContactList();
|
||||
|
||||
if (res.status === "ok") {
|
||||
return res.data;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createLazyFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createLazyFileRoute('/contacts')({
|
||||
component: () => <div>Hello /contacts!</div>
|
||||
})
|
||||
Reference in New Issue
Block a user