feat: update ui and add compose dialog

This commit is contained in:
reya
2024-07-29 14:49:13 +07:00
parent a65d5d0c1a
commit 6ceac40394
16 changed files with 913 additions and 345 deletions

View File

@@ -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") };

View File

@@ -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));
}

View File

@@ -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>
</>

View File

@@ -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": [

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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 [];
}
},
});

View File

@@ -1,5 +0,0 @@
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/contacts')({
component: () => <div>Hello /contacts!</div>
})