feat: add cache, fix ui

This commit is contained in:
2026-09-18 10:45:11 +08:00
parent 7b0de7056d
commit edf28006b2
10 changed files with 372 additions and 63 deletions
+152
View File
@@ -0,0 +1,152 @@
export interface SWRCacheOptions {
/**
* Data becomes stale after this duration.
* Stale data is returned immediately while a refresh happens.
*
* Default: 60 seconds
*/
minTimeToStale?: number;
/**
* Data is completely discarded after this duration.
* Requests wait for a fresh fetch.
*
* Default: Infinity
*/
maxTimeToLive?: number;
}
export interface SWRCacheResult<T> {
value: T;
stale: boolean;
updatedAt: number;
}
interface CacheEntry<T> {
value?: T;
updatedAt: number;
promise?: Promise<T>;
revalidating: boolean;
}
export class SWRCache {
private cache = new Map<string, CacheEntry<any>>();
async get<T>(
key: string,
fetcher: () => Promise<T>,
options: SWRCacheOptions = {},
): Promise<SWRCacheResult<T>> {
const {
minTimeToStale = 60_000,
maxTimeToLive = Number.POSITIVE_INFINITY,
} = options;
const now = Date.now();
let entry = this.cache.get(key) as CacheEntry<T> | undefined;
// No cache
if (!entry) {
entry = {
updatedAt: now,
revalidating: false,
};
entry.promise = fetcher().then((value) => {
entry!.value = value;
entry!.updatedAt = Date.now();
entry!.promise = undefined;
return value;
});
this.cache.set(key, entry);
return {
value: await entry.promise,
stale: false,
updatedAt: entry.updatedAt,
};
}
// Initial fetch still running
if (entry.promise) {
return {
value: await entry.promise,
stale: false,
updatedAt: entry.updatedAt,
};
}
const age = now - entry.updatedAt;
// Fresh
if (age < minTimeToStale) {
return {
value: entry.value!,
stale: false,
updatedAt: entry.updatedAt,
};
}
// Expired
if (age >= maxTimeToLive) {
entry.promise = fetcher().then((value) => {
entry!.value = value;
entry!.updatedAt = Date.now();
entry!.promise = undefined;
return value;
});
return {
value: await entry.promise,
stale: false,
updatedAt: entry.updatedAt,
};
}
// Stale -> return immediately and refresh in background
if (!entry.revalidating) {
entry.revalidating = true;
void fetcher()
.then((value) => {
entry!.value = value;
entry!.updatedAt = Date.now();
})
.catch(() => {
// ignore
})
.finally(() => {
entry!.revalidating = false;
});
}
return {
value: entry.value!,
stale: true,
updatedAt: entry.updatedAt,
};
}
invalidate(key: string) {
this.cache.delete(key);
}
clear() {
this.cache.clear();
}
has(key: string) {
return this.cache.has(key);
}
peek<T>(key: string): T | undefined {
return this.cache.get(key)?.value;
}
}
const cacheMgr = new SWRCache();
export default cacheMgr;
+18 -5
View File
@@ -3,6 +3,7 @@ import { WebSocketServer, WebSocket } from "ws";
import type { BridgeRequest, RpcResponse } from "../shared/protocol"; import type { BridgeRequest, RpcResponse } from "../shared/protocol";
import { initDb, insertMessage } from "./db"; import { initDb, insertMessage } from "./db";
import type { MessageDTO } from "../shared/dto"; import type { MessageDTO } from "../shared/dto";
import cacheMgr from "./lib/cache";
const PORT = 8787; const PORT = 8787;
@@ -198,8 +199,10 @@ wss.on("connection", (ws) => {
let result: unknown; let result: unknown;
const wa = getWhatsApp(request.deviceId); const wa = getWhatsApp(request.deviceId);
const { method, params } = request;
const deviceId = request.deviceId || "";
switch (request.method) { switch (method) {
case "listDevices": case "listDevices":
result = [...bridgeSockets].map((ws) => ({ result = [...bridgeSockets].map((ws) => ({
id: ws.deviceId, id: ws.deviceId,
@@ -208,16 +211,26 @@ wss.on("connection", (ws) => {
})); }));
break; break;
case "status": // cached
case "listChats":
case "listContacts": case "listContacts":
case "listGroups": case "listChats":
const res = await cacheMgr.get(
method + ":" + deviceId,
async () => {
return call(wa, method, params);
},
{ minTimeToStale: method === "listContacts" ? 60 * 60_000 : 5_000 },
);
result = res.value;
break;
case "status":
case "getMessages": case "getMessages":
case "sendText": case "sendText":
case "sendFile": case "sendFile":
case "getMedia": case "getMedia":
case "exec": case "exec":
result = await call(wa, request.method, request.params); result = await call(wa, method, params);
break; break;
default: default:
+12 -25
View File
@@ -1,3 +1,4 @@
import SearchInput from "@/components/search-input";
import Avatar from "@/components/ui/avatar"; import Avatar from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useChats } from "@/hooks/useChats"; import { useChats } from "@/hooks/useChats";
@@ -10,9 +11,7 @@ import {
ImageIcon, ImageIcon,
PaperclipIcon, PaperclipIcon,
PhoneIcon, PhoneIcon,
SearchIcon,
UsersIcon, UsersIcon,
XIcon,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@@ -36,6 +35,7 @@ export default function ChatsSection({ selected, onSelect }: Props) {
...i, ...i,
contact, contact,
title: title:
i.contact?.notifyName ||
i.contact?.formattedName || i.contact?.formattedName ||
i.contact?.name || i.contact?.name ||
i.formattedTitle || i.formattedTitle ||
@@ -56,6 +56,9 @@ export default function ChatsSection({ selected, onSelect }: Props) {
(chat) => (chat) =>
chat.name?.toLowerCase().includes(query.toLowerCase()) || chat.name?.toLowerCase().includes(query.toLowerCase()) ||
chat.formattedTitle?.toLowerCase().includes(query.toLowerCase()) || chat.formattedTitle?.toLowerCase().includes(query.toLowerCase()) ||
chat.contact?.notifyName
?.toLowerCase()
.includes(query.toLowerCase()) ||
chat.contact?.name?.toLowerCase().includes(query.toLowerCase()) || chat.contact?.name?.toLowerCase().includes(query.toLowerCase()) ||
chat.contact?.formattedName chat.contact?.formattedName
?.toLowerCase() ?.toLowerCase()
@@ -70,23 +73,7 @@ export default function ChatsSection({ selected, onSelect }: Props) {
return ( return (
<> <>
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="flex items-center gap-2 rounded-xl bg-muted px-3 py-2.5"> <SearchInput onValueChange={setQuery} placeholder="Search chats..." />
<SearchIcon size={17} className="text-muted-foreground" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
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={() => {
setQuery("");
}}
>
<XIcon size={16} className="text-muted-foreground" />
</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">
@@ -137,12 +124,12 @@ export default function ChatsSection({ selected, onSelect }: Props) {
from={chat.isGroup ? chat.from : null} from={chat.isGroup ? chat.from : null}
/> />
{/* <div className="flex-1" /> <div className="flex-1" />
{chat.unreadCount ? ( {chat.unreadCount ? (
<span className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground"> <span className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{chat.unreadCount} {chat.unreadCount}
</span> </span>
) : null} */} ) : null}
</div> </div>
</div> </div>
</button> </button>
+61 -7
View File
@@ -25,6 +25,7 @@ import { useIntersection } from "@/hooks/useIntersection";
import Paragraph from "@/components/paragraph"; import Paragraph from "@/components/paragraph";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import SearchInput from "@/components/search-input";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@@ -36,10 +37,16 @@ type Props = {
export default function MessagesSection({ chatId, maxCount = 50 }: Props) { export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
const { client, isReady } = useWAClient(); const { client, isReady } = useWAClient();
const router = useHashRouter(); const router = useHashRouter();
const msgContainerRef = useRef<HTMLDivElement>(null!);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: chats } = useChats(); const { data: chats } = useChats();
const { data: contacts } = useContacts(); const { data: contacts } = useContacts();
const [search, setSearch] = useState("");
useEffect(() => {
if (chatId) msgContainerRef.current.scrollTop = 0;
}, [chatId]);
const { const {
data: messages, data: messages,
@@ -94,8 +101,26 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
); );
const messageList = useMemo(() => { const messageList = useMemo(() => {
const q = search.trim().toLowerCase();
return messages return messages
?.filter((i) => !["revoked"].includes(i.type as never)) ?.filter((i) => {
if (["revoked"].includes(i.type as never)) {
return false;
}
if (q) {
const body = i.caption || (i.type === "chat" ? i.body : "") || "";
const name = getFormattedName(i.from) || "";
return (
body.toLowerCase().includes(q) ||
name.toLowerCase().includes(q) ||
i.filename?.toLowerCase().includes(q)
);
}
return true;
})
?.map((i) => { ?.map((i) => {
const from = contacts?.find( const from = contacts?.find(
(c) => getId(c.id) === getId(i.author || i.from), (c) => getId(c.id) === getId(i.author || i.from),
@@ -120,14 +145,26 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
quotedMsg, quotedMsg,
}; };
}); });
}, [messages, contacts]); }, [messages, contacts, search]);
function getMentionLabel(username: string) {
const data = contacts?.find((c) => (c.id as any).user === username);
return data?.notifyName || data?.formattedName || data?.name || username;
}
function onMentionClick(username: string) {
const data = contacts?.find((c) => (c.id as any).user === username);
if (data) {
router.push(`/?chatId=${getId(data.id)}`);
}
}
return ( return (
<section <section
className={`flex min-w-0 h-full flex-1 flex-col bg-muted ${chatId ? "flex" : "hidden md:flex"}`} className={`flex min-w-0 h-full flex-1 flex-col bg-muted ${chatId ? "flex" : "hidden md:flex"}`}
> >
<header className="flex items-center justify-between border-b border-border bg-card px-4 py-3 sm:px-6"> <header className="flex items-center justify-between border-b border-border bg-card px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3 w-full">
<button <button
aria-label="Back to conversations" aria-label="Back to conversations"
onClick={() => router.goBack()} onClick={() => router.goBack()}
@@ -141,7 +178,7 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
className="size-10" className="size-10"
/> />
<div className="min-w-0"> <div className="min-w-0 flex-1 truncate">
<h2 className="truncate text-sm font-semibold"> <h2 className="truncate text-sm font-semibold">
{chat?.formattedTitle || chat?.name} {chat?.formattedTitle || chat?.name}
</h2> </h2>
@@ -150,6 +187,14 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
{selected.online ? "Online now" : "Active recently"} {selected.online ? "Online now" : "Active recently"}
</p> */} </p> */}
</div> </div>
<SearchInput
className="w-full max-w-40 md:max-w-sm"
inputClassName="text-xs placeholder:text-xs"
placeholder="Search..."
defaultValue={search}
onValueChange={setSearch}
/>
</div> </div>
{/* <div className="flex items-center gap-1 text-muted-foreground"> {/* <div className="flex items-center gap-1 text-muted-foreground">
<button <button
@@ -173,7 +218,10 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
</div> */} </div> */}
</header> </header>
<div className="message-pattern flex-1 px-4 py-6 sm:px-8 flex flex-col-reverse overflow-y-auto"> <div
ref={msgContainerRef}
className="message-pattern flex-1 px-4 py-6 sm:px-8 flex flex-col-reverse overflow-y-auto"
>
{/* <div className="mb-6 flex items-center justify-center gap-2 text-[11px] text-muted-foreground"> {/* <div className="mb-6 flex items-center justify-center gap-2 text-[11px] text-muted-foreground">
<span className="h-px flex-1 bg-border" /> <span className="h-px flex-1 bg-border" />
<span>Today</span> <span>Today</span>
@@ -245,6 +293,8 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
? item.quotedMsg.body ? item.quotedMsg.body
: "Quoted") : "Quoted")
} }
getMentionLabel={getMentionLabel}
onMentionClick={onMentionClick}
/> />
</button> </button>
) : null} ) : null}
@@ -257,7 +307,11 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
) : null} ) : null}
{item.type === "chat" || item.caption ? ( {item.type === "chat" || item.caption ? (
<Paragraph text={item.caption || item.body} /> <Paragraph
text={item.caption || item.body}
getMentionLabel={getMentionLabel}
onMentionClick={onMentionClick}
/>
) : null} ) : null}
{item.type === "call_log" && ( {item.type === "call_log" && (
@@ -310,7 +364,7 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
)) ))
: null} : null}
<div ref={endScrollRef} className="h-1" /> {messages != null && <div ref={endScrollRef} className="h-1" />}
<div className="mb-6 flex items-center justify-center gap-2 text-[11px] text-muted-foreground"> <div className="mb-6 flex items-center justify-center gap-2 text-[11px] text-muted-foreground">
<span className="h-px flex-1 bg-border" /> <span className="h-px flex-1 bg-border" />
+3 -1
View File
@@ -45,7 +45,9 @@ export default function Page() {
<ChatsSection <ChatsSection
selected={chatId} selected={chatId}
onSelect={(id) => router.push(`/?chatId=${id}`)} onSelect={(id) =>
router.navigate(`/?chatId=${id}`, { replace: !!chatId })
}
/> />
{/* <div className="border-t border-border px-5 py-4"> {/* <div className="border-t border-border px-5 py-4">
+33 -6
View File
@@ -7,11 +7,20 @@ type Token =
| { type: "italic"; value: string } | { type: "italic"; value: string }
| { type: "strike"; value: string } | { type: "strike"; value: string }
| { type: "code"; value: string } | { type: "code"; value: string }
| { type: "url"; value: string }; | { type: "url"; value: string }
| { type: "mention"; value: string };
function renderWhatsAppMessage(message: string): React.ReactNode { type RenderOptions = {
getMentionLabel?: (username: string) => string | null;
onMentionClick?: (username: string) => void;
};
function renderWhatsAppMessage(
message: string,
options?: RenderOptions,
): React.ReactNode {
const regex = const regex =
/(\*[^*\n]+\*|_[^_\n]+_|~[^~\n]+~|`[^`\n]+`|https?:\/\/[^\s<]+|www\.[^\s<]+)/g; /(\*[^*\n]+\*|_[^_\n]+_|~[^~\n]+~|`[^`\n]+`|https?:\/\/[^\s<]+|www\.[^\s<]+|@\d+)/g;
const tokens: Token[] = []; const tokens: Token[] = [];
let lastIndex = 0; let lastIndex = 0;
@@ -35,6 +44,11 @@ function renderWhatsAppMessage(message: string): React.ReactNode {
tokens.push({ type: "strike", value: value.slice(1, -1) }); tokens.push({ type: "strike", value: value.slice(1, -1) });
} else if (value.startsWith("`")) { } else if (value.startsWith("`")) {
tokens.push({ type: "code", value: value.slice(1, -1) }); tokens.push({ type: "code", value: value.slice(1, -1) });
} else if (value.startsWith("@")) {
tokens.push({
type: "mention",
value: value.slice(1), // remove @
});
} else { } else {
// Remove punctuation that commonly follows a URL. // Remove punctuation that commonly follows a URL.
const url = value.replace(/[.,!?;:)]+$/, ""); const url = value.replace(/[.,!?;:)]+$/, "");
@@ -110,23 +124,36 @@ function renderWhatsAppMessage(message: string): React.ReactNode {
</a> </a>
); );
case "mention":
const label = options?.getMentionLabel?.(token.value);
return (
<button
key={i}
type="button"
onClick={() => options?.onMentionClick?.(token.value)}
className="font-medium text-blue-200 cursor-pointer hover:underline"
>
@{label || token.value}
</button>
);
case "text": case "text":
return <React.Fragment key={i}>{token.value}</React.Fragment>; return <React.Fragment key={i}>{token.value}</React.Fragment>;
} }
}); });
} }
type Props = { type Props = RenderOptions & {
text?: string | null; text?: string | null;
className?: string; className?: string;
}; };
export default function Paragraph({ text, className }: Props) { export default function Paragraph({ text, className, ...options }: Props) {
if (!text) return null; if (!text) return null;
return ( return (
<div className={cn("whitespace-pre-wrap wrap-break-word", className)}> <div className={cn("whitespace-pre-wrap wrap-break-word", className)}>
{renderWhatsAppMessage(text)} {renderWhatsAppMessage(text, options)}
</div> </div>
); );
} }
+64
View File
@@ -0,0 +1,64 @@
import { useDebounceFn } from "@/hooks/useDebounce";
import { cn } from "cn";
import { SearchIcon, XIcon } from "lucide-react";
import React, { useEffect } from "react";
type Props = React.ComponentProps<"input"> & {
onValueChange?: (value: string) => void;
inputClassName?: string;
};
export default function SearchInput({
onValueChange,
className,
inputClassName,
...props
}: Props) {
const ref = React.useRef<HTMLInputElement>(null!);
const closeRef = React.useRef<HTMLButtonElement>(null!);
const debounce = useDebounceFn();
useEffect(() => {
ref.current.value = String(props.value || "");
}, [props.value]);
function onClear() {
ref.current.value = "";
closeRef.current.style.display = "none";
onValueChange?.("");
}
return (
<div
className={cn(
"flex items-center gap-2 rounded-xl bg-muted px-3 py-2.5",
className,
)}
>
<SearchIcon size={17} className="text-muted-foreground" />
<input
ref={ref}
className={cn(
"min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground",
inputClassName,
)}
defaultValue=""
onChange={(e) => {
const value = e.target.value;
closeRef.current.style.display = value.length ? "block" : "none";
debounce(() => onValueChange?.(value), 500);
}}
{...props}
/>
<button
ref={closeRef}
aria-label="Close search"
className="hidden cursor-pointer"
onClick={onClear}
>
<XIcon size={16} className="text-muted-foreground" />
</button>
</div>
);
}
+19 -18
View File
@@ -10,22 +10,22 @@ type Props = {
}; };
const colorSchemes = [ const colorSchemes = [
"bg-rose-200 text-rose-800", "text-rose-200 bg-rose-800",
"bg-pink-200 text-pink-800", "text-pink-200 bg-pink-800",
"bg-fuchsia-200 text-fuchsia-800", "text-fuchsia-200 bg-fuchsia-800",
"bg-purple-200 text-purple-800", "text-purple-200 bg-purple-800",
"bg-violet-200 text-violet-800", "text-violet-200 bg-violet-800",
"bg-indigo-200 text-indigo-800", "text-indigo-200 bg-indigo-800",
"bg-blue-200 text-blue-800", "text-blue-200 bg-blue-800",
"bg-sky-200 text-sky-800", "text-sky-200 bg-sky-800",
"bg-cyan-200 text-cyan-800", "text-cyan-200 bg-cyan-800",
"bg-teal-200 text-teal-800", "text-teal-200 bg-teal-800",
"bg-emerald-200 text-emerald-800", "text-emerald-200 bg-emerald-800",
"bg-green-200 text-green-800", "text-green-200 bg-green-800",
"bg-lime-200 text-lime-800", "text-lime-200 bg-lime-800",
"bg-yellow-200 text-yellow-800", "text-yellow-200 bg-yellow-800",
"bg-amber-200 text-amber-800", "text-amber-200 bg-amber-800",
"bg-orange-200 text-orange-800", "text-orange-200 bg-orange-800",
]; ];
function getColorBasedOnName(name?: string | null) { function getColorBasedOnName(name?: string | null) {
@@ -41,6 +41,7 @@ export default function Avatar({
iconClassName, iconClassName,
}: Props) { }: Props) {
const color = getColorBasedOnName(name); const color = getColorBasedOnName(name);
const initial = getInitial(name);
return ( return (
<div <div
@@ -52,8 +53,8 @@ export default function Avatar({
> >
{icon ? ( {icon ? (
icon icon
) : name ? ( ) : initial ? (
getInitial(name) initial
) : ( ) : (
<UserIcon className={cn("size-4", iconClassName)} /> <UserIcon className={cn("size-4", iconClassName)} />
)} )}
-1
View File
@@ -16,7 +16,6 @@ export function useContacts() {
return { return {
...res, ...res,
findById(id: ContactDTO["id"]) { findById(id: ContactDTO["id"]) {
if (!id) return null;
return res.data?.find((i) => getId(i.id) === getId(id)); return res.data?.find((i) => getId(i.id) === getId(id));
}, },
}; };
+10
View File
@@ -0,0 +1,10 @@
import { useRef } from "react";
export function useDebounceFn(delay: number = 500) {
const timeoutRef = useRef<any>(null);
return (fn: any, delay2?: number) => {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(fn, delay2 || delay);
};
}