diff --git a/src/server/lib/cache.ts b/src/server/lib/cache.ts new file mode 100644 index 0000000..8055a7c --- /dev/null +++ b/src/server/lib/cache.ts @@ -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 { + value: T; + stale: boolean; + updatedAt: number; +} + +interface CacheEntry { + value?: T; + updatedAt: number; + + promise?: Promise; + revalidating: boolean; +} + +export class SWRCache { + private cache = new Map>(); + + async get( + key: string, + fetcher: () => Promise, + options: SWRCacheOptions = {}, + ): Promise> { + const { + minTimeToStale = 60_000, + maxTimeToLive = Number.POSITIVE_INFINITY, + } = options; + + const now = Date.now(); + + let entry = this.cache.get(key) as CacheEntry | 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(key: string): T | undefined { + return this.cache.get(key)?.value; + } +} + +const cacheMgr = new SWRCache(); + +export default cacheMgr; diff --git a/src/server/main.ts b/src/server/main.ts index 734d425..1c15654 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -3,6 +3,7 @@ import { WebSocketServer, WebSocket } from "ws"; import type { BridgeRequest, RpcResponse } from "../shared/protocol"; import { initDb, insertMessage } from "./db"; import type { MessageDTO } from "../shared/dto"; +import cacheMgr from "./lib/cache"; const PORT = 8787; @@ -198,8 +199,10 @@ wss.on("connection", (ws) => { let result: unknown; const wa = getWhatsApp(request.deviceId); + const { method, params } = request; + const deviceId = request.deviceId || ""; - switch (request.method) { + switch (method) { case "listDevices": result = [...bridgeSockets].map((ws) => ({ id: ws.deviceId, @@ -208,16 +211,26 @@ wss.on("connection", (ws) => { })); break; - case "status": - case "listChats": + // cached 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 "sendText": case "sendFile": case "getMedia": case "exec": - result = await call(wa, request.method, request.params); + result = await call(wa, method, params); break; default: diff --git a/ui/src/app/chats.tsx b/ui/src/app/chats.tsx index d064abb..d3a8ed6 100644 --- a/ui/src/app/chats.tsx +++ b/ui/src/app/chats.tsx @@ -1,3 +1,4 @@ +import SearchInput from "@/components/search-input"; import Avatar from "@/components/ui/avatar"; import { Skeleton } from "@/components/ui/skeleton"; import { useChats } from "@/hooks/useChats"; @@ -10,9 +11,7 @@ import { ImageIcon, PaperclipIcon, PhoneIcon, - SearchIcon, UsersIcon, - XIcon, } from "lucide-react"; import { useMemo, useState } from "react"; @@ -36,6 +35,7 @@ export default function ChatsSection({ selected, onSelect }: Props) { ...i, contact, title: + i.contact?.notifyName || i.contact?.formattedName || i.contact?.name || i.formattedTitle || @@ -56,6 +56,9 @@ export default function ChatsSection({ selected, onSelect }: Props) { (chat) => chat.name?.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?.formattedName ?.toLowerCase() @@ -70,23 +73,7 @@ export default function ChatsSection({ selected, onSelect }: Props) { return ( <>
-
- - setQuery(event.target.value)} - placeholder="Search conversations" - className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" - /> - -
+
{/*
@@ -137,12 +124,12 @@ export default function ChatsSection({ selected, onSelect }: Props) { from={chat.isGroup ? chat.from : null} /> - {/*
- {chat.unreadCount ? ( - - {chat.unreadCount} - - ) : null} */} +
+ {chat.unreadCount ? ( + + {chat.unreadCount} + + ) : null}
diff --git a/ui/src/app/messages.tsx b/ui/src/app/messages.tsx index cdd88f8..4c56946 100644 --- a/ui/src/app/messages.tsx +++ b/ui/src/app/messages.tsx @@ -25,6 +25,7 @@ import { useIntersection } from "@/hooks/useIntersection"; import Paragraph from "@/components/paragraph"; import { nanoid } from "nanoid"; import { Skeleton } from "@/components/ui/skeleton"; +import SearchInput from "@/components/search-input"; dayjs.extend(relativeTime); @@ -36,10 +37,16 @@ type Props = { export default function MessagesSection({ chatId, maxCount = 50 }: Props) { const { client, isReady } = useWAClient(); const router = useHashRouter(); + const msgContainerRef = useRef(null!); const queryClient = useQueryClient(); const { data: chats } = useChats(); const { data: contacts } = useContacts(); + const [search, setSearch] = useState(""); + + useEffect(() => { + if (chatId) msgContainerRef.current.scrollTop = 0; + }, [chatId]); const { data: messages, @@ -94,8 +101,26 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) { ); const messageList = useMemo(() => { + const q = search.trim().toLowerCase(); + 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) => { const from = contacts?.find( (c) => getId(c.id) === getId(i.author || i.from), @@ -120,14 +145,26 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) { 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 (
-
+
-
+
{/*
Today @@ -245,6 +293,8 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) { ? item.quotedMsg.body : "Quoted") } + getMentionLabel={getMentionLabel} + onMentionClick={onMentionClick} /> ) : null} @@ -257,7 +307,11 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) { ) : null} {item.type === "chat" || item.caption ? ( - + ) : null} {item.type === "call_log" && ( @@ -310,7 +364,7 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) { )) : null} -
+ {messages != null &&
}
diff --git a/ui/src/app/page.tsx b/ui/src/app/page.tsx index 7133b30..7f74329 100644 --- a/ui/src/app/page.tsx +++ b/ui/src/app/page.tsx @@ -45,7 +45,9 @@ export default function Page() { router.push(`/?chatId=${id}`)} + onSelect={(id) => + router.navigate(`/?chatId=${id}`, { replace: !!chatId }) + } /> {/*
diff --git a/ui/src/components/paragraph.tsx b/ui/src/components/paragraph.tsx index ecea727..90a47bc 100644 --- a/ui/src/components/paragraph.tsx +++ b/ui/src/components/paragraph.tsx @@ -7,11 +7,20 @@ type Token = | { type: "italic"; value: string } | { type: "strike"; 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 = - /(\*[^*\n]+\*|_[^_\n]+_|~[^~\n]+~|`[^`\n]+`|https?:\/\/[^\s<]+|www\.[^\s<]+)/g; + /(\*[^*\n]+\*|_[^_\n]+_|~[^~\n]+~|`[^`\n]+`|https?:\/\/[^\s<]+|www\.[^\s<]+|@\d+)/g; const tokens: Token[] = []; let lastIndex = 0; @@ -35,6 +44,11 @@ function renderWhatsAppMessage(message: string): React.ReactNode { tokens.push({ type: "strike", value: value.slice(1, -1) }); } else if (value.startsWith("`")) { tokens.push({ type: "code", value: value.slice(1, -1) }); + } else if (value.startsWith("@")) { + tokens.push({ + type: "mention", + value: value.slice(1), // remove @ + }); } else { // Remove punctuation that commonly follows a URL. const url = value.replace(/[.,!?;:)]+$/, ""); @@ -110,23 +124,36 @@ function renderWhatsAppMessage(message: string): React.ReactNode { ); + case "mention": + const label = options?.getMentionLabel?.(token.value); + return ( + + ); + case "text": return {token.value}; } }); } -type Props = { +type Props = RenderOptions & { text?: string | null; className?: string; }; -export default function Paragraph({ text, className }: Props) { +export default function Paragraph({ text, className, ...options }: Props) { if (!text) return null; return (
- {renderWhatsAppMessage(text)} + {renderWhatsAppMessage(text, options)}
); } diff --git a/ui/src/components/search-input.tsx b/ui/src/components/search-input.tsx new file mode 100644 index 0000000..df49148 --- /dev/null +++ b/ui/src/components/search-input.tsx @@ -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(null!); + const closeRef = React.useRef(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 ( +
+ + { + const value = e.target.value; + closeRef.current.style.display = value.length ? "block" : "none"; + debounce(() => onValueChange?.(value), 500); + }} + {...props} + /> + + +
+ ); +} diff --git a/ui/src/components/ui/avatar.tsx b/ui/src/components/ui/avatar.tsx index 33c209b..a34222d 100644 --- a/ui/src/components/ui/avatar.tsx +++ b/ui/src/components/ui/avatar.tsx @@ -10,22 +10,22 @@ type Props = { }; 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", + "text-rose-200 bg-rose-800", + "text-pink-200 bg-pink-800", + "text-fuchsia-200 bg-fuchsia-800", + "text-purple-200 bg-purple-800", + "text-violet-200 bg-violet-800", + "text-indigo-200 bg-indigo-800", + "text-blue-200 bg-blue-800", + "text-sky-200 bg-sky-800", + "text-cyan-200 bg-cyan-800", + "text-teal-200 bg-teal-800", + "text-emerald-200 bg-emerald-800", + "text-green-200 bg-green-800", + "text-lime-200 bg-lime-800", + "text-yellow-200 bg-yellow-800", + "text-amber-200 bg-amber-800", + "text-orange-200 bg-orange-800", ]; function getColorBasedOnName(name?: string | null) { @@ -41,6 +41,7 @@ export default function Avatar({ iconClassName, }: Props) { const color = getColorBasedOnName(name); + const initial = getInitial(name); return (
{icon ? ( icon - ) : name ? ( - getInitial(name) + ) : initial ? ( + initial ) : ( )} diff --git a/ui/src/hooks/useContact.ts b/ui/src/hooks/useContact.ts index 2dda8a4..3e2b152 100644 --- a/ui/src/hooks/useContact.ts +++ b/ui/src/hooks/useContact.ts @@ -16,7 +16,6 @@ export function useContacts() { return { ...res, findById(id: ContactDTO["id"]) { - if (!id) return null; return res.data?.find((i) => getId(i.id) === getId(id)); }, }; diff --git a/ui/src/hooks/useDebounce.ts b/ui/src/hooks/useDebounce.ts new file mode 100644 index 0000000..b9ee023 --- /dev/null +++ b/ui/src/hooks/useDebounce.ts @@ -0,0 +1,10 @@ +import { useRef } from "react"; + +export function useDebounceFn(delay: number = 500) { + const timeoutRef = useRef(null); + + return (fn: any, delay2?: number) => { + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(fn, delay2 || delay); + }; +}