Compare commits

..
19 Commits
Author SHA1 Message Date
khairul169 166c96caf2 feat: add device select & cmd exec 2026-09-22 14:42:36 +08:00
khairul169 b7bbce7374 fix: filter only authenticated socket 2026-09-22 14:21:10 +08:00
khairul169 21c30393fc feat: server update 2026-09-22 14:14:13 +08:00
khairul169 999ecc1a61 feat: add select deviceId 2026-09-22 07:45:40 +08:00
khairul169 495ee9c040 fix: hide overflow reply msg 2026-09-21 09:28:50 +08:00
khairul169 6513dc39b6 feat: add reply msg 2026-09-21 09:21:18 +08:00
khairul169 0ece6bbb63 fix: duplicated msg event fix 2026-09-21 08:59:35 +08:00
khairul169 07dfb7d443 fix: add msg skeleton padding 2026-09-20 10:29:38 +08:00
khairul169 c9c55f56c8 feat: add forward msg 2026-09-20 09:29:07 +08:00
khairul169 c2af4f9f6b feat: refresh chat list on return to main menu 2026-09-20 08:25:12 +08:00
khairul169 088e5e29f8 fix: adjust listChats maxTTL cache 2026-09-20 08:12:21 +08:00
khairul169 4d70805581 feat: add remove message feature 2026-09-18 22:45:15 +08:00
khairul169 edf28006b2 feat: add cache, fix ui 2026-09-18 10:45:11 +08:00
khairul169 7b0de7056d fix: search chat performance fix 2026-09-18 07:42:54 +08:00
khairul169 13989c0702 feat: update ui 2026-09-18 07:30:23 +08:00
khairul169 be41a5b188 chore: update manifest 2026-09-18 07:19:06 +08:00
khairul169 89dfd21adb fix: overflow container fix 2026-09-18 07:16:53 +08:00
khairul169 8aa10d38b1 feat: add skeleton loader 2026-09-18 07:10:38 +08:00
khairul169 d9e627cc5f chore: send script version when ready 2026-09-18 06:50:41 +08:00
33 changed files with 2271 additions and 234 deletions
+2 -1
View File
@@ -2,7 +2,7 @@
// ==UserScript==
// @name WhatsApp Web Bridge
// @namespace https://rul.sh/
// @version 0.0.8
// @version 0.0.9
// @description WhatsApp Web WebSocket bridge
// @match https://web.whatsapp.com/*
// @updateURL https://git.rul.sh/khairul169/whatsapp-bridge/raw/branch/main/dist/whatsapp-bridge.user.js
@@ -342,6 +342,7 @@
type: "event",
event: "ready",
data: {
version: "0.0.9",
authenticated: WPP.conn.isAuthenticated(),
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME
+2 -2
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -22,6 +22,7 @@ await esbuild.build({
target: "es2020",
define: {
__VERSION__: JSON.stringify(VERSION),
__WS_URL__: JSON.stringify(WS_URL),
},
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "whatsapp-bridge",
"module": "index.ts",
"type": "module",
"version": "0.0.8",
"version": "0.0.9",
"private": true,
"scripts": {
"build": "bun run esbuild.mjs",
+16 -4
View File
@@ -16,6 +16,7 @@ export class WhatsAppClient {
private url: string = "";
private socket: WebSocket | null = null;
private deviceId: string | undefined = undefined;
private pending = new Map<
string,
@@ -28,8 +29,13 @@ export class WhatsAppClient {
private listeners: Map<string, (...args: any[]) => void> = new Map();
constructor(url = "ws://127.0.0.1:8787") {
constructor(url = "ws://127.0.0.1:8787", deviceId?: string) {
this.url = url;
this.deviceId = deviceId;
}
setDeviceId(id?: string | null) {
this.deviceId = id || undefined;
}
async connect() {
@@ -83,12 +89,17 @@ export class WhatsAppClient {
});
}
private request<T>(method: string, params?: unknown): Promise<T> {
private request<T>(
method: string,
params?: unknown,
devId?: string | null,
): Promise<T> {
if (!this.isConnected || !this.socket) {
throw new Error("WhatsApp is not connected");
}
const id = crypto.randomUUID();
const deviceId = devId || this.deviceId;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
@@ -106,6 +117,7 @@ export class WhatsAppClient {
this.socket!.send(
JSON.stringify({
type: "request",
deviceId,
id,
method,
params,
@@ -167,7 +179,7 @@ export class WhatsAppClient {
return this.request<string>("getMedia", msgId);
}
exec(cmd: string) {
return this.request<any>("exec", cmd);
exec(cmd: string, deviceId?: string | null) {
return this.request<any>("exec", cmd, deviceId);
}
}
+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;
+76 -19
View File
@@ -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;
@@ -27,15 +28,37 @@ function requestId() {
return crypto.randomUUID();
}
function getDevices() {
return [
...new Map(
[...bridgeSockets].reverse().map((ws) => [
ws.deviceId,
{
id: ws.deviceId,
name: ws.deviceName,
lastAlive: ws.lastAlive,
lastSuccessfulCall: ws.lastSuccessfulCall,
},
]),
).values(),
];
}
function getWhatsApp(deviceId?: string): WebSocket | null {
for (const ws of [...bridgeSockets].reverse()) {
if (
ws.readyState === WebSocket.OPEN &&
(!deviceId || ws.deviceId === deviceId)
) {
return ws;
if (deviceId) {
for (const ws of [...bridgeSockets].reverse()) {
if (ws.readyState === WebSocket.OPEN && ws.deviceId === deviceId) {
return ws;
}
}
}
// find the most recent active bridge
const sock = [...bridgeSockets]
.sort((a, b) => (b.lastSuccessfulCall || 0) - (a.lastSuccessfulCall || 0))
.find((i) => i.readyState === WebSocket.OPEN && i.isAuthenticated);
if (sock) return sock;
return null;
}
@@ -74,6 +97,18 @@ function call<T>(
});
}
const forwardedMessages = new Map<string, number>();
function cleanupForwardedMessages() {
const now = Date.now();
for (const [id, timestamp] of forwardedMessages) {
if (now - timestamp > 60_000) {
forwardedMessages.delete(id);
}
}
}
function handleWhatsAppMessage(ws: WebSocket, data: string) {
const message = JSON.parse(data) as
| RpcResponse
@@ -118,21 +153,29 @@ function handleWhatsAppMessage(ws: WebSocket, data: string) {
console.log("[WhatsApp event]", event, data);
if (event === "ready") {
ws.isAuthenticated = data.authenticated || false;
ws.deviceId = data.deviceId;
ws.deviceName = data.deviceName;
}
}
if (message.event === "message") {
const m = message.data as MessageDTO;
const msgId = m.id.id || m.id._serialized || "";
if (forwardedMessages.has(msgId)) {
return;
}
forwardedMessages.set(msgId, Date.now());
// forward to clients
clientSockets.forEach((ws) => {
ws.send(JSON.stringify(message));
});
const m = message.data as MessageDTO;
insertMessage({
id: m.id.id || m.id._serialized || "",
id: msgId,
chatId: m.chatId || "",
from: typeof m.from === "string" ? m.from : m.from?._serialized || "",
to: typeof m.to === "string" ? m.to : m.to?._serialized || "",
@@ -198,32 +241,45 @@ 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,
name: ws.deviceName,
lastAlive: ws.lastAlive,
}));
result = getDevices();
break;
// cached
case "listContacts":
case "listChats":
const res = await cacheMgr.get(
method + ":" + deviceId,
async () => {
return call(wa, method, params);
},
{
minTimeToStale: method === "listContacts" ? 60 * 60_000 : 5_000,
maxTimeToLive: method === "listChats" ? 60_000 : undefined,
},
);
result = res.value;
break;
case "status":
case "listChats":
case "listContacts":
case "listGroups":
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:
throw new Error(`Unsupported method: ${(request as any).method}`);
}
ws.lastSuccessfulCall = Date.now();
ws.send(
JSON.stringify({
type: "response",
@@ -260,4 +316,5 @@ wss.on("connection", (ws) => {
});
initDb();
setInterval(cleanupForwardedMessages, 30_000);
console.log(`WhatsApp bridge listening on ws://127.0.0.1:${PORT}`);
+3 -1
View File
@@ -2,8 +2,10 @@ import "ws";
declare module "ws" {
interface WebSocket {
lastAlive?: number;
isAuthenticated?: boolean;
deviceId?: string;
deviceName?: string;
lastAlive?: number;
lastSuccessfulCall?: number;
}
}
+1
View File
@@ -159,6 +159,7 @@ function connect() {
type: "event",
event: "ready",
data: {
version: __VERSION__,
authenticated: WPP.conn.isAuthenticated(),
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME,
+1
View File
@@ -1,3 +1,4 @@
declare const __VERSION__: string;
declare const __WS_URL__: string;
type SendMessageOptions = {
+28 -13
View File
@@ -1,27 +1,42 @@
import { WhatsAppClient } from "./src/client/whatsapp";
import { getId } from "./ui/src/lib/utils";
const wa = new WhatsAppClient("ws://127.0.0.1:8787");
// const wa = new WhatsAppClient("ws://127.0.0.1:8787");
const wa = new WhatsAppClient(process.env.WS_URL);
await wa.connect();
// console.log("status", await wa.status());
const devices = await wa.listDevices();
console.log("devices", devices);
// const devices = await wa.listDevices();
// console.log("devices", devices);
wa.setDeviceId("1789898358415");
// await wa.exec("window.location.reload(); return 1;");
console.log("status", await wa.status());
// const test = await wa.exec("return window.location;");
// console.log("test", test);
// const data = JSON.stringify({
// DEVICE_ID: "1789636162707",
// DEVICE_NAME: "7C-1",
// });
// console.log(data);
// await wa.exec(`return localStorage.setItem('wa-bridge-consts', '${data}');`);
// const test = await wa.exec(`return localStorage.getItem('wa-bridge-consts');`);
// const test = await wa.exec(`return document.hasFocus();`);
// const test = await wa.exec(`window.location.reload(); return 1;`);
const test = await wa.listChats();
console.log("test", test);
// const test = await wa.exec(`return GM_info.script.version;`);
// const groups = await wa.listGroups();
// console.log(groups.map((i) => ({ name: i.subject, id: getId(i.id) })));
const groupId = "[email protected]";
// const groupId = "[email protected]";
const messages = await wa.getMessages({ chatId: groupId, count: 50 });
console.log(
messages
.filter((i) => i.type === "chat")
.map((i) => ({ body: i.body, fromMe: i.fromMe })),
);
// const messages = await wa.getMessages({ chatId: groupId, count: 50 });
// console.log(
// messages
// .filter((i) => i.type === "chat")
// .map((i) => ({ body: i.body, fromMe: i.fromMe })),
// );
process.exit(0);
+3 -1
View File
@@ -21,9 +21,11 @@
"nanoid": "^6.0.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-select": "^5.10.2",
"shadcn": "^4.21.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.15"
},
"devDependencies": {
"@types/node": "^24.13.3",
+342 -4
View File
@@ -41,15 +41,21 @@ importers:
react-dom:
specifier: ^19.2.8
version: 19.3.0([email protected])
react-select:
specifier: ^5.10.2
version: 5.10.2(@types/[email protected])([email protected]([email protected]))([email protected])
shadcn:
specifier: ^4.21.0
version: 4.21.0([email protected])
version: 4.21.0([email protected])([email protected])
tailwindcss:
specifier: ^4.3.3
version: 4.3.3
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
zustand:
specifier: ^5.0.15
version: 5.0.15(@types/[email protected])([email protected])([email protected]([email protected]))
devDependencies:
'@types/node':
specifier: ^24.13.3
@@ -242,6 +248,47 @@ packages:
'@dotenvx/[email protected]':
resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==}
'@emotion/[email protected]':
resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
'@emotion/[email protected]':
resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==}
'@emotion/[email protected]':
resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
'@emotion/[email protected]':
resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==}
'@emotion/[email protected]':
resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==}
peerDependencies:
'@types/react': '*'
react: '>=16.8.0'
peerDependenciesMeta:
'@types/react':
optional: true
'@emotion/[email protected]':
resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==}
'@emotion/[email protected]':
resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==}
'@emotion/[email protected]':
resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==}
'@emotion/[email protected]':
resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==}
peerDependencies:
react: '>=16.8.0'
'@emotion/[email protected]':
resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==}
'@emotion/[email protected]':
resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==}
'@floating-ui/[email protected]':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
@@ -625,11 +672,19 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==}
'@types/[email protected]':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
'@types/[email protected]':
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
peerDependencies:
'@types/react': ^19.3.0
'@types/[email protected]':
resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==}
peerDependencies:
'@types/react': '*'
'@types/[email protected]':
resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
@@ -698,6 +753,10 @@ packages:
resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
engines: {node: '>=10.12.0'}
[email protected]:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
engines: {node: '>=10', npm: '>=6'}
[email protected]:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
@@ -803,6 +862,9 @@ packages:
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
[email protected]:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -818,6 +880,10 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
[email protected]:
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==}
engines: {node: '>=14'}
@@ -895,6 +961,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
[email protected]:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
[email protected]:
resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
engines: {node: '>=10'}
@@ -954,6 +1023,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
[email protected]:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
@@ -1023,6 +1096,9 @@ packages:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
[email protected]:
resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
[email protected]:
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
engines: {node: '>=6'}
@@ -1097,6 +1173,9 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
[email protected]:
resolution: {integrity: sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==}
engines: {node: '>=16.9.0'}
@@ -1139,6 +1218,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
[email protected]:
resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
@@ -1422,6 +1505,10 @@ packages:
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -1441,6 +1528,9 @@ packages:
resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
engines: {node: '>= 0.8'}
[email protected]:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
[email protected]:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
@@ -1607,9 +1697,16 @@ packages:
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
[email protected]:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
[email protected]:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1653,6 +1750,9 @@ packages:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
[email protected]:
resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==}
engines: {node: '>= 0.10'}
@@ -1677,6 +1777,21 @@ packages:
peerDependencies:
react: ^19.3.0
[email protected]:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
[email protected]:
resolution: {integrity: sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
[email protected]:
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
react: '>=16.6.0'
react-dom: '>=16.6.0'
[email protected]:
resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
engines: {node: '>=0.10.0'}
@@ -1696,6 +1811,11 @@ packages:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
[email protected]:
resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
engines: {node: '>= 0.4'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
@@ -1797,6 +1917,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
@@ -1837,6 +1961,13 @@ packages:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
[email protected]:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-/NXbMVASt2UbSVgmWto4aBTIAeuYY7zOELpZybmNxqpsrbo5uYHpuEPf5nkyrPng0uG5YOb+Yv6s0FyKY4mDQg==}
engines: {node: '>=10.0.0'}
@@ -1912,6 +2043,15 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
[email protected]:
resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
[email protected]:
resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
peerDependencies:
@@ -1991,6 +2131,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
[email protected]:
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==}
engines: {node: '>=18.19'}
@@ -2007,6 +2151,24 @@ packages:
[email protected]:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
[email protected]:
resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@babel/[email protected]':
@@ -2241,6 +2403,70 @@ snapshots:
'@dotenvx/[email protected]': {}
'@emotion/[email protected]':
dependencies:
'@babel/helper-module-imports': 7.29.7
'@babel/runtime': 7.29.7
'@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0
'@emotion/serialize': 1.3.3
babel-plugin-macros: 3.1.0
convert-source-map: 1.9.0
escape-string-regexp: 4.0.0
find-root: 1.1.0
source-map: 0.5.7
stylis: 4.2.0
transitivePeerDependencies:
- supports-color
'@emotion/[email protected]':
dependencies:
'@emotion/memoize': 0.9.0
'@emotion/sheet': 1.4.0
'@emotion/utils': 1.4.2
'@emotion/weak-memoize': 0.4.0
stylis: 4.2.0
'@emotion/[email protected]': {}
'@emotion/[email protected]': {}
'@emotion/[email protected](@types/[email protected])([email protected])':
dependencies:
'@babel/runtime': 7.29.7
'@emotion/babel-plugin': 11.13.5
'@emotion/cache': 11.14.0
'@emotion/serialize': 1.3.3
'@emotion/use-insertion-effect-with-fallbacks': 1.2.0([email protected])
'@emotion/utils': 1.4.2
'@emotion/weak-memoize': 0.4.0
hoist-non-react-statics: 3.3.2
react: 19.3.0
optionalDependencies:
'@types/react': 19.3.0
transitivePeerDependencies:
- supports-color
'@emotion/[email protected]':
dependencies:
'@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0
'@emotion/unitless': 0.10.0
'@emotion/utils': 1.4.2
csstype: 3.2.3
'@emotion/[email protected]': {}
'@emotion/[email protected]': {}
'@emotion/[email protected]([email protected])':
dependencies:
react: 19.3.0
'@emotion/[email protected]': {}
'@emotion/[email protected]': {}
'@floating-ui/[email protected]':
dependencies:
'@floating-ui/utils': 0.2.12
@@ -2512,10 +2738,16 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/[email protected]': {}
'@types/[email protected](@types/[email protected])':
dependencies:
'@types/react': 19.3.0
'@types/[email protected](@types/[email protected])':
dependencies:
'@types/react': 19.3.0
'@types/[email protected]':
dependencies:
csstype: 3.2.3
@@ -2561,6 +2793,12 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@babel/runtime': 7.29.7
cosmiconfig: 7.1.0
resolve: 1.22.12
[email protected]: {}
[email protected]: {}
@@ -2658,6 +2896,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -2669,6 +2909,14 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
[email protected]:
dependencies:
'@types/parse-json': 4.0.2
import-fresh: 3.3.1
parse-json: 5.2.0
path-type: 4.0.0
yaml: 1.10.3
[email protected]([email protected]):
dependencies:
env-paths: 2.2.1
@@ -2698,7 +2946,9 @@ snapshots:
dependencies:
ms: 2.1.3
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
babel-plugin-macros: 3.1.0
[email protected]: {}
@@ -2719,6 +2969,11 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@babel/runtime': 7.29.7
csstype: 3.2.3
[email protected]:
dependencies:
is-obj: 2.0.0
@@ -2767,6 +3022,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -2884,6 +3141,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected]: {}
[email protected]:
dependencies:
locate-path: 3.0.0
@@ -2950,6 +3209,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
[email protected]:
dependencies:
react-is: 16.13.1
[email protected]: {}
[email protected]:
@@ -2983,6 +3246,10 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
hasown: 2.0.4
[email protected]: {}
[email protected]: {}
@@ -3175,6 +3442,10 @@ snapshots:
chalk: 5.6.2
is-unicode-supported: 1.3.0
[email protected]:
dependencies:
js-tokens: 4.0.0
[email protected]:
dependencies:
yallist: 3.1.1
@@ -3191,6 +3462,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -3345,8 +3618,12 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -3383,6 +3660,12 @@ snapshots:
kleur: 3.0.3
sisteransi: 1.0.5
[email protected]:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
[email protected]:
dependencies:
forwarded: 0.2.0
@@ -3409,6 +3692,34 @@ snapshots:
react: 19.3.0
scheduler: 0.28.0
[email protected]: {}
[email protected](@types/[email protected])([email protected]([email protected]))([email protected]):
dependencies:
'@babel/runtime': 7.29.7
'@emotion/cache': 11.14.0
'@emotion/react': 11.14.0(@types/[email protected])([email protected])
'@floating-ui/dom': 1.8.0
'@types/react-transition-group': 4.4.12(@types/[email protected])
memoize-one: 6.0.0
prop-types: 15.8.1
react: 19.3.0
react-dom: 19.3.0([email protected])
react-transition-group: 4.4.5([email protected]([email protected]))([email protected])
use-isomorphic-layout-effect: 1.2.1(@types/[email protected])([email protected])
transitivePeerDependencies:
- '@types/react'
- supports-color
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
'@babel/runtime': 7.29.7
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
react: 19.3.0
react-dom: 19.3.0([email protected])
[email protected]: {}
[email protected]:
@@ -3425,6 +3736,13 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
es-errors: 1.3.0
is-core-module: 2.17.0
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
[email protected]:
dependencies:
onetime: 7.0.0
@@ -3504,7 +3822,7 @@ snapshots:
[email protected]: {}
[email protected]([email protected]):
[email protected]([email protected])([email protected]):
dependencies:
'@babel/core': 7.29.7
'@babel/parser': 7.29.8
@@ -3517,7 +3835,7 @@ snapshots:
cn: 0.2.6
commander: 14.0.3
cosmiconfig: 9.0.2([email protected])
dedent: 1.7.2
dedent: 1.7.2([email protected])
deepmerge: 4.3.1
diff: 8.0.4
execa: 9.6.1
@@ -3594,6 +3912,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -3626,6 +3946,10 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -3684,6 +4008,12 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
[email protected](@types/[email protected])([email protected]):
dependencies:
react: 19.3.0
optionalDependencies:
'@types/react': 19.3.0
[email protected]([email protected]):
dependencies:
react: 19.3.0
@@ -3723,6 +4053,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
yoctocolors: 2.2.0
@@ -3734,3 +4066,9 @@ snapshots:
zod: 3.25.76
[email protected]: {}
[email protected](@types/[email protected])([email protected])([email protected]([email protected])):
optionalDependencies:
'@types/react': 19.3.0
react: 19.3.0
use-sync-external-store: 1.7.0([email protected])
+19 -1
View File
@@ -1 +1,19 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
{
"name": "WhatsApp Bridge",
"short_name": "WA Bridge",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#171717",
"background_color": "#171717",
"display": "standalone"
}
+94 -61
View File
@@ -1,4 +1,6 @@
import SearchInput from "@/components/search-input";
import Avatar from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import { useChats } from "@/hooks/useChats";
import { useContacts } from "@/hooks/useContact";
import { getFormattedName } from "@/lib/utils";
@@ -9,11 +11,9 @@ import {
ImageIcon,
PaperclipIcon,
PhoneIcon,
SearchIcon,
UsersIcon,
XIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
type Props = {
selected: string | null;
@@ -23,49 +23,68 @@ export default function ChatsSection({ selected, onSelect }: Props) {
const [query, setQuery] = useState("");
const contacts = useContacts();
const { data: chats } = useChats({
const {
data: chats,
isLoading,
refetch,
} = useChats({
refetchInterval: 10_000,
});
useEffect(() => {
// Refetch chat list when returning to main screen
if (!selected) {
refetch();
}
}, [selected]);
const chatList = useMemo(() => {
return (
chats?.map((i) => {
const contact = i.accountLid ? contacts.findById(i.accountLid) : null;
return {
...i,
contact,
title:
i.contact?.notifyName ||
i.contact?.formattedName ||
i.contact?.name ||
i.formattedTitle ||
i.name,
from: i.isGroup
? contacts.findById(i.previewMessage?.author || null)
: null,
};
}) || []
);
}, [chats, contacts]);
const filteredChats = useMemo(() => {
let items = chats || [];
let items = chatList || [];
if (query.trim().length > 0) {
items = items.filter(
(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()
.includes(query.toLowerCase()) ||
chat.lastMessage?.body?.toLowerCase().includes(query.toLowerCase()),
);
}
return items.slice(0, 100).map((i) => ({
...i,
from: i.isGroup
? contacts.findById(i.previewMessage?.author || null)
: null,
}));
}, [chats, query]);
return items.slice(0, 100);
}, [chatList, query]);
return (
<>
<div className="px-4 py-3">
<div className="flex items-center gap-2 rounded-xl bg-muted px-3 py-2.5">
<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>
<SearchInput onValueChange={setQuery} placeholder="Search chats..." />
</div>
{/* <div className="flex items-center gap-2 px-5 pb-2 text-xs font-medium text-muted-foreground">
@@ -77,42 +96,56 @@ export default function ChatsSection({ selected, onSelect }: Props) {
</div> */}
<div className="flex-1 overflow-y-auto px-2 pb-3">
{filteredChats?.map((chat) => (
<button
key={chat.id}
onClick={() => onSelect(chat.id)}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition cursor-pointer ${selected === chat.id ? "bg-accent" : "hover:bg-muted/70"}`}
>
<Avatar
name={chat.formattedTitle || chat.name}
icon={chat.isGroup ? <UsersIcon size={16} /> : null}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-semibold">
{chat.formattedTitle || chat.name}
</p>
<span className="shrink-0 text-[11px] text-muted-foreground">
{dayjs((chat.timestamp || 0) * 1000).fromNow()}
</span>
{chats == null || isLoading
? [...Array(20)].map((_, i) => (
<div key={i} className="w-full flex items-center gap-3 px-3 mb-4">
<Skeleton className="size-12 rounded-full bg-accent" />
<div className="flex-1">
<Skeleton className="flex-1 h-4 bg-accent mb-2 w-1/2" />
<Skeleton className="flex-1 h-11 bg-accent" />
</div>
</div>
<div className="mt-1 flex items-center gap-0.5 text-xs text-muted-foreground">
<ChatPreviewMessage
data={chat.previewMessage}
from={chat.isGroup ? chat.from : null}
/>
))
: null}
{/* <div className="flex-1" />
{chat.unreadCount ? (
<span className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{chat.unreadCount}
{filteredChats?.map((chat) => {
const title = chat.title || "No title";
return (
<button
key={chat.id}
onClick={() => onSelect(chat.id)}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition cursor-pointer ${selected === chat.id ? "bg-accent" : "hover:bg-muted/70"}`}
>
<Avatar
name={title}
icon={chat.isGroup ? <UsersIcon size={16} /> : null}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-semibold">{title}</p>
<span className="shrink-0 text-[11px] text-muted-foreground">
{dayjs((chat.timestamp || 0) * 1000).fromNow()}
</span>
) : null} */}
</div>
<div className="mt-1 flex items-center gap-0.5 text-xs text-muted-foreground">
<ChatPreviewMessage
data={chat.previewMessage}
from={chat.isGroup ? chat.from : null}
/>
<div className="flex-1" />
{chat.unreadCount ? (
<span className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{chat.unreadCount}
</span>
) : null}
</div>
</div>
</div>
</button>
))}
</button>
);
})}
</div>
</>
);
+105 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import { useWAClient } from "./context";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Select,
SelectContent,
@@ -8,12 +8,27 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { createDisclosure } from "@/lib/utils";
import { FileTerminalIcon } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
type Props = {
curDeviceId: string;
setDeviceId: (id: string) => void;
};
const commandDlg = createDisclosure<{ deviceId: string }>();
export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
const { client, isReady } = useWAClient();
const { data: devices } = useQuery({
@@ -35,7 +50,7 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
const isOnline = Date.now() - lastAlive <= 60_000;
return {
label: i.name || "Unknown",
label: i.name || i.id || "Unknown",
value: i.id || String(idx),
lastAlive,
isOnline,
@@ -48,7 +63,7 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
}, [deviceList, curDeviceId]);
return (
<div className="border-t p-4">
<div className="border-t p-4 flex items-center gap-2">
<Select
items={deviceList}
value={curDeviceId}
@@ -86,6 +101,92 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
))}
</SelectContent>
</Select>
<Button
onClick={() => commandDlg.onOpen({ deviceId: curDeviceId })}
variant="outline"
>
<FileTerminalIcon />
</Button>
<CommandModal />
</div>
);
}
const cmdPreset = [
{ title: "Status", command: "status" },
{ title: "Has Focus", command: "return document.hasFocus();" },
{ title: "Reload", command: "window.location.reload(); return true;" },
];
function CommandModal() {
const { data, isOpen } = commandDlg.useStore();
const { client } = useWAClient();
const cmdTextRef = useRef<HTMLTextAreaElement>(null);
const cmd = useMutation({
async mutationFn(cmd: string) {
if (cmd === "status") {
return client.status();
}
return client.exec(cmd, data?.deviceId);
},
});
const onSubmit = () => {
const command = cmdTextRef.current?.value || "";
cmd.mutate(command);
};
return (
<Dialog open={isOpen} onOpenChange={commandDlg.onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Execute Command</DialogTitle>
</DialogHeader>
<div>
<Textarea ref={cmdTextRef} placeholder="cmd..." />
<div className="flex items-center gap-2 overflow-x-auto mt-2">
{cmdPreset.map((i) => (
<button
key={i.command}
onClick={() => (cmdTextRef.current!.value = i.command)}
>
<Badge className="cursor-pointer">{i.title}</Badge>
</button>
))}
</div>
{cmd.error ? (
<div className="mt-3">
<p className="text-sm font-medium leading-tight">Error:</p>
<pre className="text-sm text-destructive mt-1">
{JSON.stringify(cmd.error, null, 2)}
</pre>
</div>
) : null}
{cmd.data !== undefined ? (
<div className="mt-3">
<p className="text-sm font-medium leading-tight">Result:</p>
<pre className="text-sm mt-1">
{JSON.stringify((cmd as any).data, null, 2)}
</pre>
</div>
) : null}
</div>
<DialogFooter>
<DialogClose>
<Button variant="ghost">Cancel</Button>
</DialogClose>
<Button onClick={onSubmit} disabled={cmd.isPending}>
Execute
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+394 -93
View File
@@ -1,18 +1,29 @@
import { useWAClient } from "./context";
import { getFilesAsBase64, getFormattedName, getId } from "@/lib/utils";
import {
copyToClipboard,
getFilesAsBase64,
getFormattedName,
getId,
} from "@/lib/utils";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { cn } from "cn";
import {
ArrowLeftIcon,
CheckCheckIcon,
CheckIcon,
ChevronDown,
CopyIcon,
CornerUpRightIcon,
Loader2Icon,
MousePointer2Icon,
PaperclipIcon,
PhoneCallIcon,
ReplyIcon,
SendIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import MessageMedia from "@/components/message-media";
@@ -24,6 +35,18 @@ import Avatar from "@/components/ui/avatar";
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";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { forwardDlg } from "./stores";
import type { ContactDTO, MessageDTO } from "@shared/dto";
dayjs.extend(relativeTime);
@@ -32,13 +55,23 @@ type Props = {
maxCount?: number;
};
export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
type MessageDTOWithStrID = Omit<MessageDTO, "id" | "from"> & {
id: string;
from?: ContactDTO;
};
export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
const { client, isReady } = useWAClient();
const router = useHashRouter();
const msgContainerRef = useRef<HTMLDivElement>(null!);
const lastFetch = useRef<number>(0);
const queryClient = useQueryClient();
const { data: chats } = useChats();
const { data: contacts } = useContacts();
const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string> | null>(null);
const [replyTo, setReplyTo] = useState<MessageDTOWithStrID | null>(null);
const {
data: messages,
@@ -68,6 +101,13 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
select: (data) => data.pages.flat(),
});
useEffect(() => {
if (chatId) {
msgContainerRef.current.scrollTop = 0;
setSelectedIds(null);
}
}, [chatId]);
useMessageEvent((data) => {
if (data.chatId !== chatId) return;
@@ -82,9 +122,15 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
});
const endScrollRef = useIntersection(() => {
if (hasNextPage && !isFetchingNextPage && !isLoading) {
fetchNextPage();
}
if (!hasNextPage || isFetchingNextPage || isLoading) return;
const t = Date.now() - lastFetch.current;
if (t < 1000) return;
fetchNextPage().then(() => {
lastFetch.current = Date.now();
msgContainerRef.current.scrollTop -= 10;
});
});
const chat = useMemo(
@@ -93,8 +139,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),
@@ -103,6 +167,7 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
const quotedMsg = i.quotedMsg
? {
...i.quotedMsg,
fromMe: i.fromMe,
from: contacts?.find((c) => {
return (
getId(c.id) ===
@@ -119,60 +184,126 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
quotedMsg,
};
});
}, [messages, contacts]);
}, [messages, contacts, search]);
function onSelectMsg(id: string) {
setSelectedIds((s) => {
const set = new Set(s || []);
if (!set.has(id)) {
set.add(id);
} else {
set.delete(id);
}
return !set.size ? null : set;
});
}
async function onDeleteMsg(id: string | string[]) {
if (!window.confirm("Delete this message?")) return;
try {
await client.exec(
`return WPP.chat.deleteMessage('${chatId}', ${JSON.stringify(id)}, true, true);`,
);
refetch();
} catch (err) {
console.error(err);
}
}
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 (
<section
className={`flex min-w-0 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">
<div className="flex min-w-0 items-center gap-3">
<button
aria-label="Back to conversations"
onClick={() => router.goBack()}
className="grid size-9 place-items-center rounded-full text-muted-foreground hover:bg-muted md:hidden cursor-pointer -ml-2"
>
<ArrowLeftIcon size={19} />
</button>
{selectedIds?.size ? (
<div className="flex items-center gap-3 w-full">
<button
aria-label="Cancel selection"
onClick={() => setSelectedIds(null)}
className="grid size-10 place-items-center rounded-full text-muted-foreground hover:bg-muted cursor-pointer -ml-2"
>
<XIcon size={19} />
</button>
<p className="text-sm font-medium truncate">{`${selectedIds.size} selected`}</p>
<div className="flex-1" />
<Avatar
name={chat?.formattedTitle || chat?.name}
className="size-10"
/>
<Button
variant="ghost"
size="icon-lg"
className="rounded-full"
title="Forward"
onClick={() => {
forwardDlg.onOpen({ messageIds: [...selectedIds] });
setSelectedIds(null);
}}
>
<CornerUpRightIcon />
</Button>
<Button
variant="ghost"
size="icon-lg"
className="rounded-full"
title="Delete"
onClick={() => {
onDeleteMsg([...selectedIds]);
setSelectedIds(null);
}}
>
<Trash2Icon />
</Button>
</div>
) : (
<div className="flex min-w-0 items-center gap-3 w-full">
<button
aria-label="Back to conversations"
onClick={() => router.goBack()}
className="grid size-9 place-items-center rounded-full text-muted-foreground hover:bg-muted md:hidden cursor-pointer -ml-2"
>
<ArrowLeftIcon size={19} />
</button>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold">
{chat?.formattedTitle || chat?.name}
</h2>
<Avatar
name={chat?.formattedTitle || chat?.name}
className="size-10 -ml-2"
/>
{/* <p className="text-xs text-[#1ea66a]">
<div className="min-w-0 flex-1 truncate">
<h2 className="truncate text-sm font-semibold">
{chat?.formattedTitle || chat?.name}
</h2>
{/* <p className="text-xs text-[#1ea66a]">
{selected.online ? "Online now" : "Active recently"}
</p> */}
</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">
<button
aria-label="Start video call"
className="grid size-9 place-items-center rounded-full hover:bg-muted"
>
<VideoIcon size={18} />
</button>
<button
aria-label="Start voice call"
className="grid size-9 place-items-center rounded-full hover:bg-muted"
>
<Phone size={18} />
</button>
<button
aria-label="Chat details"
className="grid size-9 place-items-center rounded-full hover:bg-muted"
>
<EllipsisVertical size={18} />
</button>
</div> */}
)}
</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 py-6 flex flex-col-reverse overflow-y-auto"
>
{/* <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>Today</span>
@@ -183,15 +314,98 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
<div
id={item.id.replace(/^(?:false_|true_)/, "")}
key={`${item.timestamp}-${index}`}
className={`mb-3 flex ${item.fromMe ? "justify-end" : "justify-start"}`}
className={cn(
"flex px-4 sm:px-8 py-1.5",
item.fromMe ? "justify-end" : "justify-start",
selectedIds != null && "cursor-pointer hover:bg-[#26855c]/10",
selectedIds?.has(item.id) && "bg-[#26855c]/30!",
)}
role={selectedIds != null ? "button" : undefined}
onClick={
selectedIds != null
? (e) => {
if (e.target !== e.currentTarget) return;
onSelectMsg(item.id);
}
: undefined
}
>
<div className="max-w-[82%] sm:max-w-[65%]">
<p className="mb-1 text-xs text-muted-foreground">
{getFormattedName(item.from) || item.notifyName}
</p>
<div
className={`rounded-2xl px-4 py-2.5 text-sm shadow-sm ${item.fromMe ? "rounded-br-md bg-[#d8f5e8] dark:bg-[#26855c] text-foreground" : "rounded-bl-md bg-card text-card-foreground"}`}
<a
href="#"
onClick={(e) => {
e.preventDefault();
router.push(
`/?chatId=${getId(item.author || item.from?.id)}`,
);
}}
className="mb-1 text-xs text-muted-foreground"
>
{getFormattedName(item.from) || item.notifyName}
</a>
<div
className={cn(
"rounded-2xl px-4 py-2.5 text-sm shadow-sm relative group rounded-tr-md",
item.fromMe
? "rounded-br-md bg-[#d8f5e8] dark:bg-[#26855c] text-foreground"
: "rounded-bl-md bg-card text-card-foreground",
)}
>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="absolute top-0 right-0 z-1 md:opacity-0 group-hover:opacity-100"
/>
}
>
<ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setReplyTo(item as never)}>
<ReplyIcon />
Reply
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onSelectMsg(item.id)}>
<MousePointer2Icon />
Select
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
copyToClipboard(
item.caption ||
(item.type === "chat" ? item.body : null) ||
"",
)
}
>
<CopyIcon />
Copy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
forwardDlg.onOpen({ messageIds: [item.id] })
}
>
<CornerUpRightIcon />
Forward
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDeleteMsg(item.id)}>
<Trash2Icon />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{item.isForwarded ? (
<p className="text-xs italic mb-1 text-primary/80">
Forwarded
@@ -206,37 +420,11 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
["chat", "image", "document"].includes(
item.quotedMsg.type as string,
) ? (
<button
type="button"
onClick={() => {
const id = getId(item.quotedMsgId).replace(
/^(?:false_|true_)/,
"",
);
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth" });
}
}}
className={cn(
"bg-card/30 px-2 py-1.5 rounded-lg mb-2 mt-1 cursor-pointer block text-left",
!item.fromMe && "bg-[#d8f5e8]/30 dark:bg-[#26855c]/30",
)}
>
<p className="text-xs text-muted-foreground">
{getFormattedName(item.quotedMsg.from)}
</p>
<Paragraph
text={
item.quotedMsg.filename ||
item.quotedMsg.caption ||
(item.quotedMsg.type === "chat"
? item.quotedMsg.body
: "Quoted")
}
/>
</button>
<QuotedMsg
data={item.quotedMsg as never}
getMentionLabel={getMentionLabel}
onMentionClick={onMentionClick}
/>
) : null}
{item.type === "image" ||
@@ -247,7 +435,11 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
) : null}
{item.type === "chat" || item.caption ? (
<Paragraph text={item.caption || item.body} />
<Paragraph
text={item.caption || item.body}
getMentionLabel={getMentionLabel}
onMentionClick={onMentionClick}
/>
) : null}
{item.type === "call_log" && (
@@ -280,15 +472,34 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
</div>
))}
<div ref={endScrollRef} className="h-1" />
{messages == null || isLoading || isFetchingNextPage
? [...Array(10)].map((_, i) => (
<div
key={i}
className={cn(
"mb-4 shrink-0 px-4 sm:px-8 w-full max-w-[44%]",
i % 2 === 0 ? "self-start" : "self-end",
)}
>
<Skeleton className="w-20 mb-3 h-6 bg-card" />
<Skeleton
className={cn(
"w-full h-20 bg-card rounded-2xl",
["h-20", "h-32", "h-24", "h-40"][i % 4],
)}
/>
</div>
))
: null}
{messages != null && <div ref={endScrollRef} className="h-1" />}
<div className="mb-6 flex items-center justify-center gap-2 text-[11px] text-muted-foreground">
<span className="h-px flex-1 bg-border" />
<div className="flex items-center gap-2">
{isLoading ||
(isFetchingNextPage && (
<Loader2Icon className="animate-spin size-4" />
))}
{isLoading || isFetchingNextPage ? (
<Loader2Icon className="animate-spin size-4" />
) : null}
<span>
{isLoading || isFetchingNextPage
? "Loading..."
@@ -299,18 +510,74 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
</div>
</div>
<MessageInputSection chatId={chatId} refetch={refetch} />
<MessageInputSection
chatId={chatId}
refetch={refetch}
replyTo={replyTo}
setReplyTo={setReplyTo}
/>
</section>
);
}
function QuotedMsg({
data,
getMentionLabel,
onMentionClick,
className,
}: {
data: MessageDTOWithStrID;
getMentionLabel?: (username: string) => string;
onMentionClick?: (username: string) => void;
className?: string;
}) {
return (
<button
type="button"
onClick={() => {
const id = getId(data.id).replace(/^(?:false_|true_)/, "");
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth" });
}
}}
className={cn(
"bg-card/30 px-2 py-1.5 rounded-lg mb-2 mt-1 cursor-pointer block text-left",
!data.fromMe && "bg-[#d8f5e8]/30 dark:bg-[#26855c]/30",
className,
)}
>
<p className="text-xs text-muted-foreground">
{getFormattedName(data.from)}
</p>
<Paragraph
text={
data.filename ||
data.caption ||
(data.type === "chat" ? data.body : "Quoted")
}
getMentionLabel={getMentionLabel}
onMentionClick={onMentionClick}
/>
</button>
);
}
function MessageInputSection({
chatId,
refetch,
replyTo,
setReplyTo,
getMentionLabel,
}: {
chatId?: string | null;
refetch: () => void;
replyTo?: MessageDTOWithStrID | null;
setReplyTo?: (id: MessageDTOWithStrID | null) => void;
getMentionLabel?: (username: string) => string;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const { client, isReady } = useWAClient();
const [message, setMessage] = useState("");
const [files, setFiles] = useState<{ id: string; file: File }[]>([]);
@@ -356,7 +623,7 @@ function MessageInputSection({
try {
setPending(true);
const options = { waitForAck: true };
const options = { waitForAck: true, quotedMsg: replyTo?.id || undefined };
if (files.length > 0) {
const fileList = await getFilesAsBase64(files.map((f) => f.file));
@@ -380,6 +647,7 @@ function MessageInputSection({
setTimeout(refetch, 1000);
setFiles([]);
setMessage("");
setReplyTo?.(null);
} catch (err) {
alert((err as Error).message);
} finally {
@@ -393,6 +661,37 @@ function MessageInputSection({
return (
<div className="border-t border-border bg-card px-3 py-3 sm:py-4">
{replyTo != null && (
<div className="flex items-center gap-1 -mt-2 mb-1">
<ReplyIcon className="size-5" />
<QuotedMsg
data={replyTo}
getMentionLabel={getMentionLabel}
className="text-sm max-h-32 overflow-hidden"
/>
<Button
type="button"
variant="ghost"
onClick={() => setReplyTo?.(null)}
>
<XIcon />
</Button>
</div>
)}
<input
type="file"
ref={fileRef}
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) {
onFileAttached([...e.target.files]);
}
e.target.value = "";
}}
/>
{files.length > 0 && (
<div className="flex items-center gap-2 mb-3 overflow-x-auto">
{files.map((i) => (
@@ -408,6 +707,7 @@ function MessageInputSection({
<button
aria-label="Add attachment"
className="grid size-10 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-muted-foreground"
onClick={() => fileRef.current?.click()}
>
<PaperclipIcon size={19} />
</button>
@@ -424,6 +724,7 @@ function MessageInputSection({
value={message}
onChange={(event) => setMessage(event.target.value)}
onPaste={onPaste}
enterKeyHint="enter"
onKeyDown={(event) => {
if (
event.key === "Enter" &&
+17 -3
View File
@@ -3,17 +3,27 @@ import DevicesSection from "./devices";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { useHashRouter } from "@/hooks/useHashRouter";
import MessagesSection from "./messages";
import ForwardDialog from "@/components/forward-dialog";
import { useEffect } from "react";
import wa from "@/lib/wa-client";
import { useQueryClient } from "@tanstack/react-query";
export default function Page() {
const router = useHashRouter();
const queryClient = useQueryClient();
const [curDeviceId, setDeviceId] = useLocalStorage<string>("app/device", "");
const chatId = router.query.get("chatId");
useEffect(() => {
wa.setDeviceId(curDeviceId);
queryClient.invalidateQueries();
}, [curDeviceId]);
return (
<main className="bg-background text-foreground flex overflow-hidden h-screen max-h-dvh">
<main className="bg-background text-foreground flex overflow-hidden h-screen max-h-dvh items-stretch">
{/* <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
className={`flex w-full shrink-0 flex-col border-r border-border bg-card md:w-85 lg:w-1/3 max-w-md ${chatId ? "hidden md:flex" : "flex"}`}
className={`flex w-full h-full shrink-0 flex-col border-r border-border bg-card md:w-85 lg:w-1/3 max-w-md ${chatId ? "hidden md:flex" : "flex"}`}
>
{/* <header className="flex items-center justify-between border-b border-border px-5 py-5">
<div className="flex items-center gap-3">
@@ -45,7 +55,9 @@ export default function Page() {
<ChatsSection
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">
@@ -73,6 +85,8 @@ export default function Page() {
{chatId ? <MessagesSection chatId={chatId} /> : null}
{/* </div> */}
<ForwardDialog />
</main>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { createDisclosure } from "@/lib/utils";
export const forwardDlg = createDisclosure<{
messageIds: string[];
}>();
+100
View File
@@ -0,0 +1,100 @@
import { forwardDlg } from "@/app/stores";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "./ui/button";
import { useChats } from "@/hooks/useChats";
import Select, { type Option } from "@/components/ui/react-select";
import { useEffect, useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useWAClient } from "@/app/context";
import { Loader2Icon } from "lucide-react";
export default function ForwardDialog() {
const { data, isOpen } = forwardDlg.useStore();
const { client } = useWAClient();
const { data: chats } = useChats();
const [selectedChat, setSelectedChat] = useState<Option[]>([]);
const fwd = useMutation({
mutationFn: async (payload: { chatIds: string[]; msgIds: string[] }) => {
const { chatIds, msgIds } = payload;
for (const chatId of chatIds) {
await client.exec(
`return WPP.chat.forwardMessages('${chatId}', ${JSON.stringify(msgIds)});`,
);
}
},
onSuccess() {
alert("Message forwarded!");
forwardDlg.onClose();
},
onError(err) {
alert(err.message);
},
});
useEffect(() => {
if (isOpen) setSelectedChat([]);
}, [isOpen]);
const chatOptions = useMemo(() => {
return (chats || []).map((chat) => ({
value: chat.id,
label: chat.formattedTitle || chat.name,
}));
}, [chats]);
function onSubmit() {
if (
fwd.isPending ||
!data?.messageIds?.length ||
!window.confirm("Send message?")
)
return;
fwd.mutate({
chatIds: selectedChat.map((i) => i.value),
msgIds: data!.messageIds,
});
}
return (
<Dialog open={isOpen} onOpenChange={forwardDlg.onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Forward Messages</DialogTitle>
<DialogDescription>
{data?.messageIds.length} messages
</DialogDescription>
</DialogHeader>
<div>
<Select
options={chatOptions}
isMulti
value={selectedChat}
onChange={(i) => setSelectedChat(i as never)}
/>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<Button
onClick={onSubmit}
disabled={!selectedChat.length || fwd.isPending}
>
{fwd.isPending && <Loader2Icon className="mr-2 animate-spin" />}
Send Message
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+33 -6
View File
@@ -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 {
</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":
return <React.Fragment key={i}>{token.value}</React.Fragment>;
}
});
}
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 (
<div className={cn("whitespace-pre-wrap wrap-break-word", className)}>
{renderWhatsAppMessage(text)}
{renderWhatsAppMessage(text, options)}
</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 = [
"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 (
<div
@@ -52,8 +53,8 @@ export default function Avatar({
>
{icon ? (
icon
) : name ? (
getInitial(name)
) : initial ? (
initial
) : (
<UserIcon className={cn("size-4", iconClassName)} />
)}
+51
View File
@@ -0,0 +1,51 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: "badge",
variant,
},
});
}
export { Badge, badgeVariants };
+154
View File
@@ -0,0 +1,154 @@
import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "cn";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md 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}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
/>
}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("leading-none font-medium", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+269
View File
@@ -0,0 +1,269 @@
import * as React from "react";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { cn } from "cn";
import { ChevronRightIcon, CheckIcon } from "lucide-react";
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-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:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-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,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex 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 focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex 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 focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+251
View File
@@ -0,0 +1,251 @@
import BaseSelect, {
components,
type ControlProps,
type MultiValueProps,
type OptionProps,
type SingleValueProps,
type StylesConfig,
} from "react-select";
export type Option = {
value: string;
label: string;
};
// ─────────────────────────────────────────────
// Custom Control
// ─────────────────────────────────────────────
function CustomControl(props: ControlProps<Option, true>) {
return <components.Control {...props}>{props.children}</components.Control>;
}
// ─────────────────────────────────────────────
// Custom Option
// ─────────────────────────────────────────────
function CustomOption(props: OptionProps<Option, true>) {
return (
<components.Option {...props}>
<div className="flex items-center justify-between">
<span>{props.data.label}</span>
{props.isSelected && <span className="text-zinc-300">✓</span>}
</div>
</components.Option>
);
}
// ─────────────────────────────────────────────
// Custom Single Value
// ─────────────────────────────────────────────
function CustomSingleValue(props: SingleValueProps<Option>) {
return (
<components.SingleValue {...props}>
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-indigo-400" />
{props.data.label}
</div>
</components.SingleValue>
);
}
// ─────────────────────────────────────────────
// Custom Multi Value
// ─────────────────────────────────────────────
function CustomMultiValue(props: MultiValueProps<Option, true>) {
return (
<components.MultiValue {...props}>
<span>{props.data.label}</span>
</components.MultiValue>
);
}
// ─────────────────────────────────────────────
// Shadcn-inspired dark styles
// ─────────────────────────────────────────────
const darkStyles: StylesConfig<Option, true> = {
control: (base, state) => ({
...base,
minHeight: 40,
backgroundColor: "#09090b",
borderColor: state.isFocused ? "#71717a" : "#27272a",
borderRadius: 6,
boxShadow: state.isFocused ? "0 0 0 1px #71717a" : "none",
cursor: "pointer",
"&:hover": {
borderColor: "#3f3f46",
},
}),
valueContainer: (base) => ({
...base,
padding: "3px 8px",
gap: 4,
}),
input: (base) => ({
...base,
color: "#fafafa",
margin: 0,
padding: 0,
}),
placeholder: (base) => ({
...base,
color: "#71717a",
}),
singleValue: (base) => ({
...base,
color: "#fafafa",
}),
// ───────────────────────────────────────────
// Menu
// ───────────────────────────────────────────
menu: (base) => ({
...base,
marginTop: 4,
backgroundColor: "#09090b",
border: "1px solid #27272a",
borderRadius: 6,
overflow: "hidden",
boxShadow:
"0 10px 15px -3px rgb(0 0 0 / 0.3), " + "0 4px 6px -4px rgb(0 0 0 / 0.3)",
}),
menuList: (base) => ({
...base,
padding: 4,
}),
// ───────────────────────────────────────────
// Options
// ───────────────────────────────────────────
option: (base, state) => ({
...base,
borderRadius: 4,
padding: "7px 8px",
backgroundColor: state.isSelected
? "#27272a"
: state.isFocused
? "#18181b"
: "transparent",
color: state.isDisabled ? "#52525b" : "#fafafa",
cursor: state.isDisabled ? "not-allowed" : "pointer",
"&:active": {
backgroundColor: "#27272a",
},
}),
// ───────────────────────────────────────────
// Multi value
// ───────────────────────────────────────────
multiValue: (base) => ({
...base,
backgroundColor: "#27272a",
borderRadius: 4,
}),
multiValueLabel: (base) => ({
...base,
color: "#e4e4e7",
padding: "2px 6px",
}),
multiValueRemove: (base) => ({
...base,
color: "#a1a1aa",
borderRadius: "0 4px 4px 0",
"&:hover": {
backgroundColor: "#3f3f46",
color: "#fafafa",
},
}),
// ───────────────────────────────────────────
// Indicators
// ───────────────────────────────────────────
indicatorSeparator: () => ({
display: "none",
}),
dropdownIndicator: (base) => ({
...base,
color: "#71717a",
padding: "0 8px",
"&:hover": {
color: "#a1a1aa",
},
}),
clearIndicator: (base) => ({
...base,
color: "#71717a",
"&:hover": {
color: "#fafafa",
},
}),
// ───────────────────────────────────────────
// Messages
// ───────────────────────────────────────────
noOptionsMessage: (base) => ({
...base,
color: "#71717a",
}),
loadingMessage: (base) => ({
...base,
color: "#71717a",
}),
};
// ─────────────────────────────────────────────
// Select
// ─────────────────────────────────────────────
type Props = React.ComponentProps<typeof BaseSelect>;
function Select({ ...props }: Props) {
return (
<BaseSelect
styles={darkStyles}
components={{
Control: CustomControl,
Option: CustomOption,
SingleValue: CustomSingleValue,
MultiValue: CustomMultiValue,
}}
{...(props as any)}
/>
);
}
export default Select;
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "cn";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-background/30", className)}
{...props}
/>
);
}
export { Skeleton };
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import { cn } from "cn";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground 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 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
-1
View File
@@ -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));
},
};
+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);
};
}
+25 -1
View File
@@ -1,5 +1,5 @@
import type { ContactDTO, MessageIdDTO, SerializedId } from "@shared/dto";
import { createStore, useStore } from "zustand";
export { cn } from "cn";
export function getId(id?: string | SerializedId | MessageIdDTO | null) {
@@ -45,3 +45,27 @@ export async function getFilesAsBase64(files: File[]) {
}),
);
}
export function createDisclosure<T = any>(data?: T | null) {
const store = createStore(() => ({
isOpen: false,
data,
}));
return Object.assign(store, {
onOpen: (data?: T | null) => store.setState({ isOpen: true, data }),
onClose: () => store.setState({ isOpen: false }),
onOpenChange: (open: boolean) => store.setState({ isOpen: open }),
useStore: () => useStore(store),
});
}
export async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error("Failed to copy text: ", err);
return false;
}
}
+3
View File
@@ -6,6 +6,9 @@ import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
allowedHosts: true,
},
resolve: {
alias: {
"@": path.resolve(import.meta.dirname, "./src"),