feat: draft update ui

This commit is contained in:
2026-09-17 06:11:14 +08:00
parent de0666beb1
commit aa25af028e
9 changed files with 450 additions and 79 deletions
+3 -1
View File
@@ -127,7 +127,9 @@ export class WhatsAppClient {
} }
listDevices() { listDevices() {
return this.request<{ id: string; name: string }[]>("listDevices"); return this.request<{ id: string; name: string; lastAlive: number }[]>(
"listDevices",
);
} }
listChats(params?: ListChatsParams) { listChats(params?: ListChatsParams) {
+19 -39
View File
@@ -1,5 +1,6 @@
import { useWAClient } from "./context"; import { useWAClient } from "./context";
import { cn, getId, getInitial } from "@/lib/utils"; import Avatar from "@/components/ui/avatar";
import { getId } from "@/lib/utils";
import type { ChatDTO } from "@shared/dto"; import type { ChatDTO } from "@shared/dto";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { SearchIcon, XIcon } from "lucide-react"; import { SearchIcon, XIcon } from "lucide-react";
@@ -11,7 +12,6 @@ type Props = {
}; };
export default function ChatsSection({ selected, onSelect }: Props) { export default function ChatsSection({ selected, onSelect }: Props) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [showSearch, setShowSearch] = useState(false);
const { client, isReady } = useWAClient(); const { client, isReady } = useWAClient();
const { data: chats } = useQuery({ const { data: chats } = useQuery({
@@ -41,34 +41,23 @@ export default function ChatsSection({ selected, onSelect }: Props) {
return ( return (
<> <>
<div className="px-4 py-3"> <div className="px-4 py-3">
{showSearch ? ( <div className="flex items-center gap-2 rounded-xl bg-muted px-3 py-2.5">
<div className="flex items-center gap-2 rounded-xl bg-muted px-3 py-2"> <SearchIcon size={17} className="text-muted-foreground" />
<SearchIcon size={17} className="text-muted-foreground" /> <input
<input value={query}
autoFocus onChange={(event) => setQuery(event.target.value)}
value={query} placeholder="Search conversations"
onChange={(event) => setQuery(event.target.value)} className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder="Search conversations" />
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<button
aria-label="Close search"
onClick={() => {
setShowSearch(false);
setQuery("");
}}
>
<XIcon size={16} className="text-muted-foreground" />
</button>
</div>
) : (
<button <button
onClick={() => setShowSearch(true)} aria-label="Close search"
className="flex w-full items-center gap-3 rounded-xl bg-muted px-3 py-2.5 text-left text-sm text-muted-foreground transition hover:bg-border" onClick={() => {
setQuery("");
}}
> >
<SearchIcon size={17} /> Search conversations <XIcon size={16} className="text-muted-foreground" />
</button> </button>
)} </div>
</div> </div>
{/* <div className="flex items-center gap-2 px-5 pb-2 text-xs font-medium text-muted-foreground"> {/* <div className="flex items-center gap-2 px-5 pb-2 text-xs font-medium text-muted-foreground">
@@ -84,19 +73,10 @@ export default function ChatsSection({ selected, onSelect }: Props) {
<button <button
key={chat.id} key={chat.id}
onClick={() => onSelect(chat)} onClick={() => onSelect(chat)}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition ${selected?.id === chat.id ? "bg-accent" : "hover:bg-muted/70"}`} className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition cursor-pointer ${selected?.id === chat.id ? "bg-accent" : "hover:bg-muted/70"}`}
> >
<div <Avatar name={chat.formattedTitle} />
className={cn(
"relative grid size-11 shrink-0 place-items-center rounded-full text-sm font-semibold bg-rose-200 text-rose-800",
)}
>
{getInitial(chat.formattedTitle)}
{/* {chat.initials}
{chat.online && (
<span className="absolute bottom-0 right-0 size-3 rounded-full border-2 border-card bg-primary" />
)} */}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-semibold"> <p className="truncate text-sm font-semibold">
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useMemo } from "react";
import { useWAClient } from "./context";
import { useQuery } from "@tanstack/react-query";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type Props = {
curDeviceId: string;
setDeviceId: (id: string) => void;
};
export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
const { client, isReady } = useWAClient();
const { data: devices } = useQuery({
queryKey: ["devices"],
queryFn: () => client.listDevices(),
enabled: isReady,
refetchInterval: 10_000,
});
useEffect(() => {
if (!curDeviceId && devices?.length && devices[0].id) {
setDeviceId(devices[0].id);
}
}, [curDeviceId, devices]);
const deviceList = useMemo(() => {
return devices?.map((i, idx) => {
const lastAlive = i.lastAlive || 0;
const isOnline = Date.now() - lastAlive <= 60_000;
return {
label: i.name || "Unknown",
value: i.id || String(idx),
lastAlive,
isOnline,
};
});
}, [devices]);
const curDevice = useMemo(() => {
return deviceList?.find((i) => i.value === curDeviceId);
}, [deviceList, curDeviceId]);
return (
<div className="border-t p-4">
<Select
items={deviceList}
value={curDeviceId}
onValueChange={(i) => setDeviceId(i || "")}
>
<SelectTrigger className="w-full justify-start">
<SelectValue placeholder="Select Device.." className="flex-none" />
{curDevice ? (
<span
className={
"inline-block size-3 rounded-full " +
(curDevice.isOnline ? "bg-green-500" : "bg-red-500")
}
/>
) : null}
<div className="flex-1" />
</SelectTrigger>
<SelectContent>
{deviceList?.map((i) => (
<SelectItem
key={i.value}
value={i.value}
className="cursor-pointer"
>
<div className="flex items-center gap-2">
<p>{i.label}</p>
<span
className={
"inline-block size-3 rounded-full " +
(i.isOnline ? "bg-green-500" : "bg-red-500")
}
/>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
+20 -12
View File
@@ -1,13 +1,19 @@
import { useWAClient } from "./context"; import { useWAClient } from "./context";
import { getFormattedName, getId, getInitial } from "@/lib/utils"; import { getFormattedName, getId } from "@/lib/utils";
import type { ChatDTO } from "@shared/dto"; import type { ChatDTO } from "@shared/dto";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { cn } from "cn"; import { cn } from "cn";
import { ArrowLeftIcon, PaperclipIcon, SendIcon } from "lucide-react"; import {
ArrowLeftIcon,
PaperclipIcon,
PhoneCallIcon,
SendIcon,
} from "lucide-react";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import MessageMedia from "@/components/message-media"; import MessageMedia from "@/components/message-media";
import Avatar from "@/components/ui/avatar";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@@ -42,8 +48,8 @@ export default function MessagesSection({
const messageList = useMemo(() => { const messageList = useMemo(() => {
return messages return messages
?.filter((i) => !["call_log", "revoked"].includes(i.type as never)) ?.filter((i) => !["revoked"].includes(i.type as never))
.map((message) => { ?.map((message) => {
const from = contacts?.find( const from = contacts?.find(
(c) => getId(c.id) === getId(message.author || message.from), (c) => getId(c.id) === getId(message.author || message.from),
); );
@@ -81,18 +87,13 @@ export default function MessagesSection({
<button <button
aria-label="Back to conversations" aria-label="Back to conversations"
onClick={() => setMobileChat(false)} onClick={() => setMobileChat(false)}
className="grid size-9 place-items-center rounded-full text-muted-foreground hover:bg-muted md:hidden" className="grid size-9 place-items-center rounded-full text-muted-foreground hover:bg-muted md:hidden cursor-pointer"
> >
<ArrowLeftIcon size={19} /> <ArrowLeftIcon size={19} />
</button> </button>
<div <Avatar name={chat.formattedTitle || chat.name} className="size-10" />
className={cn(
"grid size-10 shrink-0 place-items-center rounded-full text-sm font-semibold",
)}
>
{getInitial(chat.formattedTitle)}
</div>
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate text-sm font-semibold"> <h2 className="truncate text-sm font-semibold">
{chat.formattedTitle || chat.name} {chat.formattedTitle || chat.name}
@@ -175,6 +176,13 @@ export default function MessagesSection({
</p> </p>
) : null} ) : null}
{item.type === "call_log" && (
<div className="flex items-center gap-2 pt-3 pb-2 px-2">
<PhoneCallIcon className="size-5" />
<p className="text-sm italic">Call log</p>
</div>
)}
<div <div
className={`mt-1 flex items-center justify-end gap-1 text-[10px] ${item.fromMe ? "text-primary" : "text-muted-foreground"}`} className={`mt-1 flex items-center justify-end gap-1 text-[10px] ${item.fromMe ? "text-primary" : "text-muted-foreground"}`}
> >
+36 -26
View File
@@ -1,19 +1,27 @@
import { useState } from "react";
import ChatsSection from "./chats"; import ChatsSection from "./chats";
import type { ChatDTO } from "@shared/dto"; import type { ChatDTO } from "@shared/dto";
import MessagesSection from "./messages"; import MessagesSection from "./messages";
import DevicesSection from "./devices";
import { useLocalStorage } from "@/hooks/useLocalStorage";
export default function Page() { export default function Page() {
const [chat, setChat] = useState<ChatDTO | null>(null); const [chat, setChat] = useLocalStorage<ChatDTO | null>(
const [mobileChat, setMobileChat] = useState(false); "app/current-chat",
null,
);
const [mobileChat, setMobileChat] = useLocalStorage<boolean>(
"app/mobile-chat",
false,
);
const [curDeviceId, setDeviceId] = useLocalStorage<string>("app/device", "");
return ( return (
<main className="min-h-dvh bg-background p-0 text-foreground sm:p-4 lg:p-6"> <main className="bg-background text-foreground flex overflow-hidden h-screen max-h-dvh">
<div className="mx-auto flex h-dvh max-w-360 overflow-hidden bg-card shadow-xl sm:h-[calc(100dvh-2rem)] sm:rounded-2xl lg:h-[calc(100dvh-3rem)]"> {/* <div className="mx-auto flex h-dvh max-w-360 overflow-hidden bg-card shadow-xl sm:h-[calc(100dvh-2rem)] sm:rounded-2xl lg:h-[calc(100dvh-3rem)]"> */}
<aside <aside
className={`flex w-full shrink-0 flex-col border-r border-border bg-card md:w-85 lg:w-95 ${mobileChat ? "hidden md:flex" : "flex"}`} className={`flex w-full shrink-0 flex-col border-r border-border bg-card md:w-85 lg:w-95 ${mobileChat ? "hidden md:flex" : "flex"}`}
> >
{/* <header className="flex items-center justify-between border-b border-border px-5 py-5"> {/* <header className="flex items-center justify-between border-b border-border px-5 py-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="grid size-10 place-items-center rounded-xl bg-accent text-accent-foreground"> <div className="grid size-10 place-items-center rounded-xl bg-accent text-accent-foreground">
<Sparkles aria-hidden="true" /> <Sparkles aria-hidden="true" />
@@ -41,15 +49,15 @@ export default function Page() {
</div> </div>
</header> */} </header> */}
<ChatsSection <ChatsSection
selected={chat} selected={chat}
onSelect={(c) => { onSelect={(c) => {
setChat(c); setChat(c);
setMobileChat(true); setMobileChat(true);
}} }}
/> />
{/* <div className="border-t border-border px-5 py-4"> {/* <div className="border-t border-border px-5 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="grid size-9 place-items-center rounded-full bg-border text-xs font-semibold text-muted-foreground"> <div className="grid size-9 place-items-center rounded-full bg-border text-xs font-semibold text-muted-foreground">
JD JD
@@ -68,16 +76,18 @@ export default function Page() {
</button> </button>
</div> </div>
</div> */} </div> */}
</aside>
{chat ? ( <DevicesSection curDeviceId={curDeviceId} setDeviceId={setDeviceId} />
<MessagesSection </aside>
chat={chat}
mobileChat={mobileChat} {chat ? (
setMobileChat={setMobileChat} <MessagesSection
/> chat={chat}
) : null} mobileChat={mobileChat}
</div> setMobileChat={setMobileChat}
/>
) : null}
{/* </div> */}
</main> </main>
); );
} }
+54
View File
@@ -0,0 +1,54 @@
import { getInitial } from "@/lib/utils";
import { cn } from "cn";
import { UserIcon } from "lucide-react";
type Props = {
name?: string | null;
className?: string;
iconClassName?: string;
};
const colorSchemes = [
"bg-rose-200 text-rose-800",
"bg-pink-200 text-pink-800",
"bg-fuchsia-200 text-fuchsia-800",
"bg-purple-200 text-purple-800",
"bg-violet-200 text-violet-800",
"bg-indigo-200 text-indigo-800",
"bg-blue-200 text-blue-800",
"bg-sky-200 text-sky-800",
"bg-cyan-200 text-cyan-800",
"bg-teal-200 text-teal-800",
"bg-emerald-200 text-emerald-800",
"bg-green-200 text-green-800",
"bg-lime-200 text-lime-800",
"bg-yellow-200 text-yellow-800",
"bg-amber-200 text-amber-800",
"bg-orange-200 text-orange-800",
];
function getColorBasedOnName(name?: string | null) {
if (!name) return colorSchemes[0];
return colorSchemes[getInitial(name).charCodeAt(0) % colorSchemes.length];
}
export default function Avatar({ name, className, iconClassName }: Props) {
const color = getColorBasedOnName(name);
return (
<div
className={cn(
"relative grid size-11 shrink-0 place-items-center rounded-full text-sm font-semibold bg-rose-200 text-rose-800",
color,
className,
)}
>
{name ? (
getInitial(name)
) : (
<UserIcon className={cn("size-4", iconClassName)} />
)}
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import * as React from "react";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { cn } from "cn";
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
);
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
);
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
export function useLocalStorage<T = unknown>(key: string, initialValue: T) {
const [state, setState] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.log(error);
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(state));
} catch (error) {
console.log(error);
}
}, [state]);
return [state, setState] as const;
}
+5 -1
View File
@@ -10,7 +10,11 @@ export function getId(id?: string | SerializedId | MessageIdDTO | null) {
export function getInitial(name?: string | null) { export function getInitial(name?: string | null) {
return ( return (
name name
?.split(" ") ?.replace("dr.", "")
.replace(/[^a-zA-Z ]/g, "")
.toUpperCase()
.trim()
.split(" ")
.map((i) => i[0]) .map((i) => i[0])
.slice(0, 2) .slice(0, 2)
.join("") || "" .join("") || ""