feat: add cache, fix ui
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user