Compare commits

...
42 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
khairul169 9792e49123 feat: add send file function 2026-09-18 01:58:44 +08:00
khairul169 6e852ab224 feat: add infinite scroll & fix some ui 2026-09-18 01:10:35 +08:00
khairul169 65a2e534d7 feat: update ui 2026-09-17 13:47:37 +08:00
khairul169 e9c1d5f3c7 chore: disable external client connect log 2026-09-17 06:15:42 +08:00
khairul169 aa25af028e feat: draft update ui 2026-09-17 06:11:14 +08:00
khairul169 de0666beb1 feat: add media storage 2026-09-17 05:23:28 +08:00
khairul169 e3d55e4b7e feat: add more functionality 2026-09-17 04:35:18 +08:00
khairul169 3fe95e925e feat: update ui 2026-09-16 11:26:58 +08:00
khairul169 d45c78a5f8 fix: chat timestamp fix 2026-09-16 11:03:55 +08:00
khairul169 19b69c7a51 feat: fetch media draft 2026-09-16 05:13:02 +08:00
khairul169 881ce919aa fix: getMedia unsupported method 2026-09-16 05:10:07 +08:00
khairul169 78faf2b070 feat: add getMedia 2026-09-16 05:07:21 +08:00
khairul169 d673055210 fix: serialize message properly 2026-09-16 04:36:21 +08:00
khairul169 9b59c1f127 feat: add preview message to chats 2026-09-16 04:33:48 +08:00
khairul169 d0edccf15d feat: add ping to prevent ws timeout 2026-09-15 18:26:35 +08:00
khairul169 501ee73e41 chore: update build 2026-09-15 18:08:59 +08:00
khairul169 3b589578af feat: update server & userscript 2026-09-15 18:07:56 +08:00
khairul169 5329bb2ed9 feat: frontend draft 2026-09-15 17:55:48 +08:00
khairul169 a13e4d2d99 fix: remove list message log 2026-09-14 18:28:02 +08:00
khairul169 193e5fff31 feat: add db 2026-09-14 18:27:27 +08:00
khairul169 e4b77e1c11 chore: update build 2026-09-14 17:32:07 +08:00
khairul169 699bfa8de5 feat: update ws url 2026-09-14 17:31:46 +08:00
khairul169 fb0b2208d6 update test client 2026-09-14 17:31:31 +08:00
70 changed files with 8584 additions and 291 deletions
+1
View File
@@ -32,3 +32,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
*.db
+55 -65
View File
@@ -2,7 +2,7 @@
// ==UserScript== // ==UserScript==
// @name WhatsApp Web Bridge // @name WhatsApp Web Bridge
// @namespace https://rul.sh/ // @namespace https://rul.sh/
// @version 0.0.1 // @version 0.0.9
// @description WhatsApp Web WebSocket bridge // @description WhatsApp Web WebSocket bridge
// @match https://web.whatsapp.com/* // @match https://web.whatsapp.com/*
// @updateURL https://git.rul.sh/khairul169/whatsapp-bridge/raw/branch/main/dist/whatsapp-bridge.user.js // @updateURL https://git.rul.sh/khairul169/whatsapp-bridge/raw/branch/main/dist/whatsapp-bridge.user.js
@@ -93,6 +93,7 @@
return Object.keys(result).length ? result : String(id); return Object.keys(result).length ? result : String(id);
} }
function serializeChat(chat) { function serializeChat(chat) {
const rawPreviewMessage = safeProperty(chat, "previewMessage");
return { return {
id: serializeId(chat.id), id: serializeId(chat.id),
accountLid: chat.accountLid, accountLid: chat.accountLid,
@@ -103,6 +104,7 @@
isReadOnly: chat.isReadOnly, isReadOnly: chat.isReadOnly,
archived: chat.archived, archived: chat.archived,
unreadCount: chat.unreadCount, unreadCount: chat.unreadCount,
previewMessage: rawPreviewMessage ? serializeMessage(rawPreviewMessage) : null,
timestamp: chat.t, timestamp: chat.t,
lastMessage: chat.lastMessage ? serialize(chat.lastMessage) : null lastMessage: chat.lastMessage ? serialize(chat.lastMessage) : null
}; };
@@ -115,6 +117,7 @@
pushname: contact.pushname ?? null, pushname: contact.pushname ?? null,
shortName: contact.shortName ?? null, shortName: contact.shortName ?? null,
formattedName: contact.formattedName ?? null, formattedName: contact.formattedName ?? null,
notifyName: contact.notifyName ?? null,
number: contact.number ?? null, number: contact.number ?? null,
isMe: contact.isMe ?? false, isMe: contact.isMe ?? false,
isUser: contact.isUser ?? false, isUser: contact.isUser ?? false,
@@ -198,12 +201,12 @@
from: serializeId(message.from), from: serializeId(message.from),
to: serializeId(message.to), to: serializeId(message.to),
author: serializeId(message.author), author: serializeId(message.author),
fromMe: message.fromMe ?? false, fromMe: message.id?.fromMe || message.fromMe || false,
body: message.body ?? null, body: message.body ?? null,
caption: message.caption ?? null, caption: message.caption ?? null,
type: message.type ?? null, type: message.type ?? null,
subtype: message.subtype ?? null, subtype: message.subtype ?? null,
timestamp: message.timestamp ?? null, timestamp: message.t ?? message.timestamp ?? null,
ack: message.ack ?? null, ack: message.ack ?? null,
hasMedia: message.hasMedia ?? false, hasMedia: message.hasMedia ?? false,
mediaKey: message.mediaKey ?? null, mediaKey: message.mediaKey ?? null,
@@ -211,6 +214,7 @@
forwardingScore: message.forwardingScore ?? 0, forwardingScore: message.forwardingScore ?? 0,
isStarred: message.isStarred ?? false, isStarred: message.isStarred ?? false,
broadcast: message.broadcast ?? false, broadcast: message.broadcast ?? false,
notifyName: message.notifyName ?? null,
mentionedIds: Array.isArray(message.mentionedIds) ? message.mentionedIds.map(serializeId) : [], mentionedIds: Array.isArray(message.mentionedIds) ? message.mentionedIds.map(serializeId) : [],
quotedMsgId: serializeMessageId(quotedMsgId), quotedMsgId: serializeMessageId(quotedMsgId),
quotedMsg, quotedMsg,
@@ -226,8 +230,22 @@
} }
// src/userscript/index.ts // src/userscript/index.ts
var WS_URL = "ws://127.0.0.1:8787";
var socket; var socket;
var consts = {
DEVICE_ID: String(Date.now()),
DEVICE_NAME: ""
};
function init() {
try {
const _consts = localStorage.getItem("wa-bridge-consts");
if (!_consts) {
localStorage.setItem("wa-bridge-consts", JSON.stringify(consts));
} else {
consts = JSON.parse(_consts);
}
} catch (_) {
}
}
function send(message) { function send(message) {
if (socket?.readyState !== WebSocket.OPEN) { if (socket?.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not connected"); throw new Error("WebSocket is not connected");
@@ -249,7 +267,7 @@
ok: false, ok: false,
error: { error: {
code: "ERROR", code: "ERROR",
message: err instanceof Error ? err.message : String(err) message: typeof err === "object" && "message" in err ? err.message : String(err)
} }
}); });
} }
@@ -258,7 +276,9 @@
case "status": case "status":
return { return {
authenticated: WPP.conn.isAuthenticated(), authenticated: WPP.conn.isAuthenticated(),
ready: WPP.isReady ready: WPP.isReady,
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME
}; };
case "listChats": { case "listChats": {
const chats = await WPP.chat.list({ const chats = await WPP.chat.list({
@@ -269,13 +289,13 @@
return chats.filter(Boolean).map(serializeChat); return chats.filter(Boolean).map(serializeChat);
} }
case "listContacts": { case "listContacts": {
const contacts = await WPP.contact.getAllContacts(); const contacts = await WPP.contact.list();
const result = request.params?.limit ? contacts.slice(0, request.params.limit) : contacts; const result = request.params?.limit ? contacts.slice(0, request.params.limit) : contacts;
return result.filter(Boolean).map(serializeContact); return result.filter(Boolean).map(serializeContact);
} }
case "listGroups": { case "listGroups": {
const groups = await WPP.group.getAllGroups(); const groups = await WPP.group.getAllGroups();
return groups.filter(Boolean).map(serializeGroupMetadata); return groups.filter((i) => i.groupMetadata != null).map((i) => serializeGroupMetadata(i.groupMetadata));
} }
case "getMessages": { case "getMessages": {
const messages = await WPP.chat.getMessages(request.params.chatId, { const messages = await WPP.chat.getMessages(request.params.chatId, {
@@ -285,87 +305,56 @@
}); });
return messages.filter(Boolean).map(serializeMessage); return messages.filter(Boolean).map(serializeMessage);
} }
case "searchMessages":
return searchMessages(
request.params.query,
request.params.chatId,
request.params.limit ?? 100
);
case "sendText": { case "sendText": {
const result = await WPP.chat.sendTextMessage( const result = await WPP.chat.sendTextMessage(
request.params.chatId, request.params.chatId,
request.params.text, request.params.text,
{ request.params.options
delay: request.params.options?.delay,
quotedMsg: request.params.options?.quotedMsg,
mentionedList: request.params.options?.mentionedList,
waitForAck: request.params.options?.waitForAck
}
); );
return serialize(result); return serialize(result);
} }
case "sendFile": {
const result = await WPP.chat.sendFileMessage(
request.params.chatId,
request.params.data,
request.params.options
);
return serialize(result);
}
case "getMedia":
const media = await WPP.chat.downloadMedia(request.params).then(WPP.util.blobToBase64);
return media;
case "exec":
const fn = new Function(request.params);
return fn();
default: default:
throw new Error(`Unknown method: ${request.method}`); throw new Error(`Unknown method: ${request.method}`);
} }
} }
async function searchMessages(query, chatId, limit = 100) {
const normalized = query.toLowerCase();
let chats;
if (chatId) {
const chat = WPP.chat.get(chatId);
chats = chat ? [chat] : [];
} else {
chats = await WPP.chat.list();
}
const results = [];
for (const chat of chats) {
if (results.length >= limit) {
break;
}
const id = chat?.id?._serialized ?? chat?.id ?? chat?.wid?._serialized;
if (!id) {
continue;
}
try {
const messages = await WPP.chat.getMessages(id, {
count: 100
});
for (const message of messages) {
const body = String(message.body ?? message.caption ?? "");
if (body.toLowerCase().includes(normalized)) {
results.push({
chatId: id,
message: serialize(message)
});
if (results.length >= limit) {
break;
}
}
}
} catch (err) {
console.warn("Failed searching chat", id, err);
}
}
return results;
}
function connect() { function connect() {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return; return;
} }
socket = new WebSocket(WS_URL); socket = new WebSocket("wss://wa-bridge.rul.sh/ws");
socket.onopen = () => { socket.onopen = () => {
console.log("[WA Bridge] connected"); console.log("[WA Bridge] connected", "wss://wa-bridge.rul.sh/ws");
send({ send({
type: "event", type: "event",
event: "ready", event: "ready",
data: { data: {
authenticated: WPP.conn.isAuthenticated() version: "0.0.9",
authenticated: WPP.conn.isAuthenticated(),
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME
} }
}); });
}; };
socket.onmessage = async (event) => { socket.onmessage = async (event) => {
try { try {
const request = JSON.parse(event.data); const request = JSON.parse(event.data);
if (request.type === "ping") {
send({ type: "pong" });
}
if (request.type !== "request") { if (request.type !== "request") {
return; return;
} }
@@ -396,18 +385,19 @@
WPP.loader.onReady(() => { WPP.loader.onReady(() => {
console.log("[WA Bridge] WA-JS ready"); console.log("[WA Bridge] WA-JS ready");
connect(); connect();
WPP.chat.on("chat.new_message", (message) => { WPP.on("chat.new_message", (message) => {
if (socket?.readyState !== WebSocket.OPEN) { if (socket?.readyState !== WebSocket.OPEN) {
return; return;
} }
send({ send({
type: "event", type: "event",
event: "message", event: "message",
data: serialize(message) data: serializeMessage(message)
}); });
}); });
}); });
} }
init();
start(); start();
})(); })();
//# sourceMappingURL=whatsapp-bridge.user.js.map //# sourceMappingURL=whatsapp-bridge.user.js.map
+2 -2
View File
File diff suppressed because one or more lines are too long
+18 -1
View File
@@ -1,4 +1,16 @@
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";
import pkgInfo from "./package.json";
const VERSION = pkgInfo.version;
const IS_DIST =
process.argv.indexOf("--prod") !== -1 || process.argv.indexOf("-p") !== -1;
const WS_URL = IS_DIST ? process.env.WS_URL : "ws://127.0.0.1:8787";
//////////////////////////////////////////////
console.log("VERSION", VERSION);
console.log("IS_DIST", IS_DIST);
console.log("WS_URL", WS_URL);
await esbuild.build({ await esbuild.build({
entryPoints: ["src/userscript/index.ts"], entryPoints: ["src/userscript/index.ts"],
@@ -9,12 +21,17 @@ await esbuild.build({
platform: "browser", platform: "browser",
target: "es2020", target: "es2020",
define: {
__VERSION__: JSON.stringify(VERSION),
__WS_URL__: JSON.stringify(WS_URL),
},
banner: { banner: {
js: ` js: `
// ==UserScript== // ==UserScript==
// @name WhatsApp Web Bridge // @name WhatsApp Web Bridge
// @namespace https://rul.sh/ // @namespace https://rul.sh/
// @version 0.0.1 // @version ${VERSION}
// @description WhatsApp Web WebSocket bridge // @description WhatsApp Web WebSocket bridge
// @match https://web.whatsapp.com/* // @match https://web.whatsapp.com/*
// @updateURL https://git.rul.sh/khairul169/whatsapp-bridge/raw/branch/main/dist/whatsapp-bridge.user.js // @updateURL https://git.rul.sh/khairul169/whatsapp-bridge/raw/branch/main/dist/whatsapp-bridge.user.js
+2 -1
View File
@@ -2,9 +2,10 @@
"name": "whatsapp-bridge", "name": "whatsapp-bridge",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"version": "0.0.9",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "node esbuild.mjs", "build": "bun run esbuild.mjs",
"server": "bun run src/server/main.ts" "server": "bun run src/server/main.ts"
}, },
"devDependencies": { "devDependencies": {
-2
View File
@@ -1,2 +0,0 @@
onlyBuiltDependencies:
- esbuild
-42
View File
@@ -1,42 +0,0 @@
import { WhatsAppClient } from "./whatsapp";
const wa = new WhatsAppClient("ws://127.0.0.1:8787");
await wa.connect();
const chats = await wa.listChats({
limit: 10,
groupsOnly: true,
// usersOnly: true,
});
console.log(
chats.map((i) => ({
id: typeof i.id === "object" ? i.id?._serialized : i.id,
lid: i.accountLid,
name: i.formattedTitle,
})),
);
const messages = await wa.getMessages({
chatId: "[email protected]",
count: 5,
});
console.log(
messages.map((i) => ({
id: i.id.id,
from: i.from,
text: i.body,
})),
);
// const result = await wa.searchMessages({
// query: "pasien",
// chatId: "[email protected]",
// limit: 20,
// });
// await wa.sendText({
// chatId: "[email protected]",
// text: "Hello from Node!",
// });
+64 -17
View File
@@ -1,9 +1,7 @@
import WebSocket from "ws";
import type { import type {
GetMessagesParams, GetMessagesParams,
ListChatsParams, ListChatsParams,
SearchMessagesParams, SendFileParams,
SendTextParams, SendTextParams,
} from "../shared/protocol"; } from "../shared/protocol";
import type { import type {
@@ -14,37 +12,53 @@ import type {
} from "../shared/dto"; } from "../shared/dto";
export class WhatsAppClient { export class WhatsAppClient {
isConnected: boolean = false;
private url: string = ""; private url: string = "";
private isConnected: boolean = false;
private socket: WebSocket | null = null; private socket: WebSocket | null = null;
private deviceId: string | undefined = undefined;
private pending = new Map< private pending = new Map<
string, string,
{ {
resolve: (value: any) => void; resolve: (value: any) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
timer: NodeJS.Timeout; timer: any;
} }
>(); >();
constructor(url = "ws://127.0.0.1:8787") { private listeners: Map<string, (...args: any[]) => void> = new Map();
constructor(url = "ws://127.0.0.1:8787", deviceId?: string) {
this.url = url; this.url = url;
this.deviceId = deviceId;
}
setDeviceId(id?: string | null) {
this.deviceId = id || undefined;
} }
async connect() { async connect() {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve) => {
if (this.socket) return; if (this.socket) return;
this.socket = new WebSocket(this.url); this.emit("connecting");
this.socket = new WebSocket(this.url + "?client=1");
this.socket.on("open", () => { this.socket.addEventListener("open", () => {
console.log("Connected to bridge"); console.log("Connected to bridge");
this.isConnected = true; this.isConnected = true;
this.emit("status", true);
resolve(); resolve();
}); });
this.socket.on("message", (data) => { this.socket.addEventListener("message", (data) => {
const message = JSON.parse(data.toString()); const message = JSON.parse(data.data);
if (message.type === "event") {
this.emit(message.event, message.data);
return;
}
if (message.type !== "response") { if (message.type !== "response") {
return; return;
@@ -66,20 +80,26 @@ export class WhatsAppClient {
} }
}); });
this.socket.on("close", () => { this.socket.addEventListener("close", () => {
this.socket = null; this.socket = null;
this.isConnected = false; this.isConnected = false;
this.emit("status", false);
setTimeout(() => this.connect(), 3000); setTimeout(() => this.connect(), 3000);
}); });
}); });
} }
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) { if (!this.isConnected || !this.socket) {
throw new Error("WhatsApp is not connected"); throw new Error("WhatsApp is not connected");
} }
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const deviceId = devId || this.deviceId;
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -97,6 +117,7 @@ export class WhatsAppClient {
this.socket!.send( this.socket!.send(
JSON.stringify({ JSON.stringify({
type: "request", type: "request",
deviceId,
id, id,
method, method,
params, params,
@@ -105,13 +126,31 @@ export class WhatsAppClient {
}); });
} }
on(event: string, callback: (...args: any[]) => void) {
this.listeners.set(event, callback);
}
private emit(event: string, ...args: any[]) {
this.listeners.get(event)?.(...args);
}
status() { status() {
if (!this.isConnected) {
return null;
}
return this.request<{ return this.request<{
authenticated: boolean; authenticated: boolean;
ready: boolean; ready: boolean;
}>("status"); }>("status");
} }
listDevices() {
return this.request<{ id: string; name: string; lastAlive: number }[]>(
"listDevices",
);
}
listChats(params?: ListChatsParams) { listChats(params?: ListChatsParams) {
return this.request<ChatDTO[]>("listChats", params); return this.request<ChatDTO[]>("listChats", params);
} }
@@ -128,11 +167,19 @@ export class WhatsAppClient {
return this.request<MessageDTO[]>("getMessages", params); return this.request<MessageDTO[]>("getMessages", params);
} }
searchMessages(params: SearchMessagesParams) {
return this.request<MessageDTO[]>("searchMessages", params);
}
sendText(params: SendTextParams) { sendText(params: SendTextParams) {
return this.request<any>("sendText", params); return this.request<any>("sendText", params);
} }
sendFile(params: SendFileParams) {
return this.request<any>("sendFile", params);
}
getMedia(msgId: string) {
return this.request<string>("getMedia", msgId);
}
exec(cmd: string, deviceId?: string | null) {
return this.request<any>("exec", cmd, deviceId);
}
} }
+54
View File
@@ -0,0 +1,54 @@
import { Database } from "bun:sqlite";
export const db = new Database(process.cwd() + "/data.db");
export interface Message {
id: string;
chatId: string;
from: string;
to: string;
fromMe: boolean;
data: any;
}
export interface Contact {
id: string;
accountLid: string;
from: string;
to: string;
fromMe: boolean;
data: any;
}
export function initDb() {
db.run(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
chat_id TEXT NOT NULL,
sender TEXT NOT NULL,
receiver TEXT NOT NULL,
from_me INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
CREATE INDEX IF NOT EXISTS idx_messages_chat_id_id ON messages(chat_id, id);
`);
}
export function insertMessage(message: Message): void {
try {
db.query(
`INSERT INTO messages (id, chat_id, sender, receiver, from_me, data)
VALUES ($id, $chatId, $from, $to, $fromMe, $data) ON CONFLICT(id) DO UPDATE SET data = $data;`,
).run({
$id: message.id,
$chatId: message.chatId,
$from: message.from,
$to: message.to,
$fromMe: message.fromMe ? 1 : 0,
$data: JSON.stringify(message.data),
});
} catch (err) {
console.error(err);
}
}
+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;
+168 -54
View File
@@ -1,6 +1,9 @@
import { WebSocketServer, WebSocket } from "ws"; 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 type { MessageDTO } from "../shared/dto";
import cacheMgr from "./lib/cache";
const PORT = 8787; const PORT = 8787;
@@ -9,7 +12,8 @@ const wss = new WebSocketServer({
port: PORT, port: PORT,
}); });
let whatsapp: WebSocket | undefined; const bridgeSockets = new Set<WebSocket>();
const clientSockets = new Set<WebSocket>();
const pending = new Map< const pending = new Map<
string, string,
@@ -24,11 +28,46 @@ function requestId() {
return crypto.randomUUID(); 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 {
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;
}
function call<T>( function call<T>(
wa: WebSocket | null,
method: BridgeRequest["method"], method: BridgeRequest["method"],
params?: unknown, params?: unknown,
): Promise<T> { ): Promise<T> {
if (!whatsapp || whatsapp.readyState !== WebSocket.OPEN) { if (!wa || wa.readyState !== WebSocket.OPEN) {
throw new Error("WhatsApp Web is not connected"); throw new Error("WhatsApp Web is not connected");
} }
@@ -47,7 +86,7 @@ function call<T>(
timer, timer,
}); });
whatsapp!.send( wa!.send(
JSON.stringify({ JSON.stringify({
type: "request", type: "request",
id, id,
@@ -58,15 +97,33 @@ function call<T>(
}); });
} }
function handleWhatsAppMessage(data: string) { 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 const message = JSON.parse(data) as
| RpcResponse | RpcResponse
| { type: "pong" }
| { | {
type: "event"; type: "event";
event: string; event: string;
data: unknown; data: unknown;
}; };
if (message.type === "pong") {
ws.lastAlive = Date.now();
return;
}
if (message.type === "response") { if (message.type === "response") {
const request = pending.get(message.id); const request = pending.get(message.id);
if (!request) { if (!request) {
@@ -76,105 +133,153 @@ function handleWhatsAppMessage(data: string) {
pending.delete(message.id); pending.delete(message.id);
clearTimeout(request.timer); clearTimeout(request.timer);
if (message.ok) { try {
if (!message.ok) {
throw new Error(message.error?.message ?? "Unknown WhatsApp error");
}
const res = JSON.parse(message.result as string); const res = JSON.parse(message.result as string);
request.resolve(res); request.resolve(res);
} else { } catch (err) {
request.reject( request.reject(err);
new Error(message.error?.message ?? "Unknown WhatsApp error"),
);
} }
return; return;
} }
if (message.type === "event") { if (message.type === "event") {
console.log("[WhatsApp event]", message.event, message.data); if (["ready"].includes(message.event)) {
const { event, data } = message as any;
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));
});
insertMessage({
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 || "",
fromMe: !!m.fromMe,
data: m,
});
}
} }
} }
wss.on("connection", (ws) => { wss.on("connection", (ws) => {
console.log("[WS] client connected"); // console.log("[WS] socket connected");
/* const url = new URL(ws.url ?? "", "http://localhost");
* First connection from Tampermonkey is the const isBridge =
* WhatsApp bridge. !url.searchParams.get("client") || url.searchParams.get("bridge") === "1";
*/
if (!whatsapp) { const ping = setInterval(() => {
whatsapp = ws; ws.send(JSON.stringify({ type: "ping" }));
}, 10_000);
const clean = () => {
clearInterval(ping);
};
if (isBridge) {
ws.lastAlive = Date.now();
bridgeSockets.add(ws);
console.log("[WS] WhatsApp bridge attached"); console.log("[WS] WhatsApp bridge attached");
ws.on("message", (data) => { ws.on("message", (data) => {
handleWhatsAppMessage(data.toString()); handleWhatsAppMessage(ws, data.toString());
}); });
ws.on("close", () => { ws.on("close", () => {
if (whatsapp === ws) { clean();
whatsapp = undefined; bridgeSockets.delete(ws);
}
console.log("[WS] WhatsApp disconnected"); console.log("[WS] WhatsApp bridge disconnected", {
deviceId: ws.deviceId,
deviceName: ws.deviceName,
});
}); });
ws.on("error", () => {
clean();
bridgeSockets.delete(ws);
});
return; return;
} }
/* clientSockets.add(ws);
* This is an external application/client.
*
* Example:
* my backend
* CLI
* React application
* another service
*/
ws.on("message", async (data) => { ws.on("message", async (data) => {
let request: any; let request!: BridgeRequest;
try { try {
request = JSON.parse(data.toString()) as BridgeRequest; request = JSON.parse(data.toString()) as BridgeRequest;
if (request.type !== "request") { if (request.type !== "request") {
return; return;
} }
let result: unknown; let result: unknown;
const wa = getWhatsApp(request.deviceId);
const { method, params } = request;
const deviceId = request.deviceId || "";
switch (request.method) { switch (method) {
case "status": case "listDevices":
result = await call("status", request.params); result = getDevices();
break;
case "listChats":
result = await call("listChats", request.params);
break; break;
// cached
case "listContacts": case "listContacts":
result = await call("listContacts", request.params); case "listChats":
break; const res = await cacheMgr.get(
method + ":" + deviceId,
case "listGroups": async () => {
result = await call("listGroups", request.params); return call(wa, method, params);
},
{
minTimeToStale: method === "listContacts" ? 60 * 60_000 : 5_000,
maxTimeToLive: method === "listChats" ? 60_000 : undefined,
},
);
result = res.value;
break; break;
case "status":
case "getMessages": case "getMessages":
result = await call("getMessages", request.params);
break;
case "searchMessages":
result = await call("searchMessages", request.params);
break;
case "sendText": case "sendText":
result = await call("sendText", request.params); case "sendFile":
case "getMedia":
case "exec":
result = await call(wa, method, params);
break; break;
default: default:
throw new Error(`Unsupported method: ${(request as any).method}`); throw new Error(`Unsupported method: ${(request as any).method}`);
} }
ws.lastSuccessfulCall = Date.now();
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "response", type: "response",
@@ -199,8 +304,17 @@ wss.on("connection", (ws) => {
}); });
ws.on("close", () => { ws.on("close", () => {
console.log("[WS] external client disconnected"); clean();
clientSockets.delete(ws);
// console.log("[WS] external client disconnected");
});
ws.on("error", () => {
clean();
clientSockets.delete(ws);
}); });
}); });
initDb();
setInterval(cleanupForwardedMessages, 30_000);
console.log(`WhatsApp bridge listening on ws://127.0.0.1:${PORT}`); console.log(`WhatsApp bridge listening on ws://127.0.0.1:${PORT}`);
+11
View File
@@ -0,0 +1,11 @@
import "ws";
declare module "ws" {
interface WebSocket {
isAuthenticated?: boolean;
deviceId?: string;
deviceName?: string;
lastAlive?: number;
lastSuccessfulCall?: number;
}
}
+4
View File
@@ -27,6 +27,7 @@ export interface ContactDTO {
pushname?: string | null; pushname?: string | null;
shortName?: string | null; shortName?: string | null;
formattedName?: string | null; formattedName?: string | null;
notifyName?: string | null;
number?: string | null; number?: string | null;
@@ -69,6 +70,8 @@ export interface ChatDTO {
lastMessage?: MessageDTO | null; lastMessage?: MessageDTO | null;
contact?: ContactDTO | null; contact?: ContactDTO | null;
groupMetadata?: GroupMetadataDTO | null; groupMetadata?: GroupMetadataDTO | null;
previewMessage?: MessageDTO | null;
} }
export interface GroupMetadataDTO { export interface GroupMetadataDTO {
@@ -134,6 +137,7 @@ export interface MessageDTO {
isStarred?: boolean; isStarred?: boolean;
broadcast?: boolean; broadcast?: boolean;
notifyName?: string | null;
mentionedIds?: (SerializedId | string)[]; mentionedIds?: (SerializedId | string)[];
+21 -13
View File
@@ -1,8 +1,9 @@
export type RequestId = string; export type RequestId = string;
export interface RpcRequest<M extends string = string, P = unknown> { export interface RpcRequest<M extends string = string, P = unknown> {
type: "request"; type: "request" | "ping";
id: RequestId; id: RequestId;
deviceId?: RequestId;
method: M; method: M;
params: P; params: P;
} }
@@ -39,22 +40,26 @@ export interface GetMessagesParams {
id?: string; id?: string;
} }
export interface SearchMessagesParams { export interface SendTextOptions {
query: string; delay?: number;
chatId?: string; quotedMsg?: string;
limit?: number; mentionedList?: string[];
waitForAck?: boolean;
caption?: string;
filename?: string;
mimetype?: string;
} }
export interface SendTextParams { export interface SendTextParams {
chatId: string; chatId: string;
text: string; text: string;
options?: SendTextOptions;
}
options?: { export interface SendFileParams {
delay?: number; chatId: string;
quotedMsg?: string; data: string;
mentionedList?: string[]; options?: SendTextOptions;
waitForAck?: boolean;
};
} }
export interface GetContactsParams { export interface GetContactsParams {
@@ -63,9 +68,12 @@ export interface GetContactsParams {
export type BridgeRequest = export type BridgeRequest =
| RpcRequest<"status"> | RpcRequest<"status">
| RpcRequest<"listDevices">
| RpcRequest<"listChats", ListChatsParams> | RpcRequest<"listChats", ListChatsParams>
| RpcRequest<"listContacts", GetContactsParams> | RpcRequest<"listContacts", GetContactsParams>
| RpcRequest<"listGroups"> | RpcRequest<"listGroups">
| RpcRequest<"getMessages", GetMessagesParams> | RpcRequest<"getMessages", GetMessagesParams>
| RpcRequest<"searchMessages", SearchMessagesParams> | RpcRequest<"sendText", SendTextParams>
| RpcRequest<"sendText", SendTextParams>; | RpcRequest<"sendFile", SendFileParams>
| RpcRequest<"getMedia", string>
| RpcRequest<"exec", string>;
+58 -81
View File
@@ -7,9 +7,22 @@ import {
serializeMessage, serializeMessage,
} from "./serializer"; } from "./serializer";
const WS_URL = "ws://127.0.0.1:8787";
let socket: WebSocket | undefined; let socket: WebSocket | undefined;
let consts: Record<string, unknown> = {
DEVICE_ID: String(Date.now()),
DEVICE_NAME: "",
};
function init() {
try {
const _consts = localStorage.getItem("wa-bridge-consts");
if (!_consts) {
localStorage.setItem("wa-bridge-consts", JSON.stringify(consts));
} else {
consts = JSON.parse(_consts);
}
} catch (_) {}
}
function send(message: unknown) { function send(message: unknown) {
if (socket?.readyState !== WebSocket.OPEN) { if (socket?.readyState !== WebSocket.OPEN) {
@@ -35,7 +48,10 @@ function error(id: string, err: unknown) {
ok: false, ok: false,
error: { error: {
code: "ERROR", code: "ERROR",
message: err instanceof Error ? err.message : String(err), message:
typeof err === "object" && "message" in err!
? err.message
: String(err),
}, },
}); });
} }
@@ -46,6 +62,8 @@ async function handleRequest(request: BridgeRequest) {
return { return {
authenticated: WPP.conn.isAuthenticated(), authenticated: WPP.conn.isAuthenticated(),
ready: WPP.isReady, ready: WPP.isReady,
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME,
}; };
case "listChats": { case "listChats": {
@@ -61,7 +79,7 @@ async function handleRequest(request: BridgeRequest) {
} }
case "listContacts": { case "listContacts": {
const contacts = await WPP.contact.getAllContacts(); const contacts = await WPP.contact.list();
const result = request.params?.limit const result = request.params?.limit
? contacts.slice(0, request.params.limit) ? contacts.slice(0, request.params.limit)
@@ -73,7 +91,9 @@ async function handleRequest(request: BridgeRequest) {
case "listGroups": { case "listGroups": {
const groups = await WPP.group.getAllGroups(); const groups = await WPP.group.getAllGroups();
return groups.filter(Boolean).map(serializeGroupMetadata); return groups
.filter((i) => i.groupMetadata != null)
.map((i) => serializeGroupMetadata(i.groupMetadata));
} }
case "getMessages": { case "getMessages": {
@@ -86,92 +106,41 @@ async function handleRequest(request: BridgeRequest) {
return messages.filter(Boolean).map(serializeMessage); return messages.filter(Boolean).map(serializeMessage);
} }
case "searchMessages":
return searchMessages(
request.params.query,
request.params.chatId,
request.params.limit ?? 100,
);
case "sendText": { case "sendText": {
const result = await WPP.chat.sendTextMessage( const result = await WPP.chat.sendTextMessage(
request.params.chatId, request.params.chatId,
request.params.text, request.params.text,
{ request.params.options,
delay: request.params.options?.delay,
quotedMsg: request.params.options?.quotedMsg,
mentionedList: request.params.options?.mentionedList,
waitForAck: request.params.options?.waitForAck,
},
); );
return serialize(result); return serialize(result);
} }
case "sendFile": {
const result = await WPP.chat.sendFileMessage(
request.params.chatId,
request.params.data,
request.params.options,
);
return serialize(result);
}
case "getMedia":
const media = await WPP.chat
.downloadMedia(request.params)
.then(WPP.util.blobToBase64);
return media;
case "exec":
const fn = new Function(request.params);
return fn();
default: default:
throw new Error(`Unknown method: ${(request as any).method}`); throw new Error(`Unknown method: ${(request as any).method}`);
} }
} }
/**
* WA-JS doesn't need a separate "global search" API for our
* first implementation. Search the messages loaded from chats.
*
* Later we can optimize this using WhatsApp's internal message
* store/index if necessary.
*/
async function searchMessages(query: string, chatId?: string, limit = 100) {
const normalized = query.toLowerCase();
let chats;
if (chatId) {
const chat = WPP.chat.get(chatId);
chats = chat ? [chat] : [];
} else {
chats = await WPP.chat.list();
}
const results: any[] = [];
for (const chat of chats) {
if (results.length >= limit) {
break;
}
const id = chat?.id?._serialized ?? chat?.id ?? chat?.wid?._serialized;
if (!id) {
continue;
}
try {
const messages = await WPP.chat.getMessages(id, {
count: 100,
});
for (const message of messages) {
const body = String(message.body ?? message.caption ?? "");
if (body.toLowerCase().includes(normalized)) {
results.push({
chatId: id,
message: serialize(message),
});
if (results.length >= limit) {
break;
}
}
}
} catch (err) {
console.warn("Failed searching chat", id, err);
}
}
return results;
}
function connect() { function connect() {
if ( if (
socket && socket &&
@@ -181,16 +150,19 @@ function connect() {
return; return;
} }
socket = new WebSocket(WS_URL); socket = new WebSocket(__WS_URL__);
socket.onopen = () => { socket.onopen = () => {
console.log("[WA Bridge] connected"); console.log("[WA Bridge] connected", __WS_URL__);
send({ send({
type: "event", type: "event",
event: "ready", event: "ready",
data: { data: {
version: __VERSION__,
authenticated: WPP.conn.isAuthenticated(), authenticated: WPP.conn.isAuthenticated(),
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME,
}, },
}); });
}; };
@@ -199,6 +171,10 @@ function connect() {
try { try {
const request = JSON.parse(event.data) as BridgeRequest; const request = JSON.parse(event.data) as BridgeRequest;
if (request.type === "ping") {
send({ type: "pong" });
}
if (request.type !== "request") { if (request.type !== "request") {
return; return;
} }
@@ -240,7 +216,7 @@ function start() {
/* /*
* Forward new-message events to Node. * Forward new-message events to Node.
*/ */
WPP.chat.on("chat.new_message", (message: unknown) => { WPP.on("chat.new_message", (message: unknown) => {
if (socket?.readyState !== WebSocket.OPEN) { if (socket?.readyState !== WebSocket.OPEN) {
return; return;
} }
@@ -248,10 +224,11 @@ function start() {
send({ send({
type: "event", type: "event",
event: "message", event: "message",
data: serialize(message), data: serializeMessage(message),
}); });
}); });
}); });
} }
init();
start(); start();
+9 -2
View File
@@ -137,6 +137,8 @@ export function serializeId(id: any): SerializedId | string | null {
} }
export function serializeChat(chat: any): ChatDTO { export function serializeChat(chat: any): ChatDTO {
const rawPreviewMessage = safeProperty<any>(chat, "previewMessage");
return { return {
id: serializeId(chat.id), id: serializeId(chat.id),
accountLid: chat.accountLid, accountLid: chat.accountLid,
@@ -148,6 +150,9 @@ export function serializeChat(chat: any): ChatDTO {
isReadOnly: chat.isReadOnly, isReadOnly: chat.isReadOnly,
archived: chat.archived, archived: chat.archived,
unreadCount: chat.unreadCount, unreadCount: chat.unreadCount,
previewMessage: rawPreviewMessage
? serializeMessage(rawPreviewMessage)
: null,
timestamp: chat.t, timestamp: chat.t,
lastMessage: chat.lastMessage ? serialize(chat.lastMessage) : null, lastMessage: chat.lastMessage ? serialize(chat.lastMessage) : null,
@@ -163,6 +168,7 @@ export function serializeContact(contact: any): ContactDTO {
pushname: contact.pushname ?? null, pushname: contact.pushname ?? null,
shortName: contact.shortName ?? null, shortName: contact.shortName ?? null,
formattedName: contact.formattedName ?? null, formattedName: contact.formattedName ?? null,
notifyName: contact.notifyName ?? null,
number: contact.number ?? null, number: contact.number ?? null,
isMe: contact.isMe ?? false, isMe: contact.isMe ?? false,
@@ -266,12 +272,12 @@ export function serializeMessage(message: any): MessageDTO {
from: serializeId(message.from), from: serializeId(message.from),
to: serializeId(message.to), to: serializeId(message.to),
author: serializeId(message.author), author: serializeId(message.author),
fromMe: message.fromMe ?? false, fromMe: message.id?.fromMe || message.fromMe || false,
body: message.body ?? null, body: message.body ?? null,
caption: message.caption ?? null, caption: message.caption ?? null,
type: message.type ?? null, type: message.type ?? null,
subtype: message.subtype ?? null, subtype: message.subtype ?? null,
timestamp: message.timestamp ?? null, timestamp: message.t ?? message.timestamp ?? null,
ack: message.ack ?? null, ack: message.ack ?? null,
hasMedia: message.hasMedia ?? false, hasMedia: message.hasMedia ?? false,
mediaKey: message.mediaKey ?? null, mediaKey: message.mediaKey ?? null,
@@ -279,6 +285,7 @@ export function serializeMessage(message: any): MessageDTO {
forwardingScore: message.forwardingScore ?? 0, forwardingScore: message.forwardingScore ?? 0,
isStarred: message.isStarred ?? false, isStarred: message.isStarred ?? false,
broadcast: message.broadcast ?? false, broadcast: message.broadcast ?? false,
notifyName: message.notifyName ?? null,
mentionedIds: Array.isArray(message.mentionedIds) mentionedIds: Array.isArray(message.mentionedIds)
? message.mentionedIds.map(serializeId) ? message.mentionedIds.map(serializeId)
: [], : [],
+34 -11
View File
@@ -1,3 +1,21 @@
declare const __VERSION__: string;
declare const __WS_URL__: string;
type SendMessageOptions = {
delay?: number;
detectMentioned?: boolean;
markIsRead?: boolean;
mentionedList?: string[];
messageId?: string;
quotedMsg?: string;
quotedMsgPayload?: string;
waitForAck?: boolean;
isViewOnce?: boolean;
filename?: string;
mimetype?: string;
type?: string;
};
declare const WPP: { declare const WPP: {
isReady: boolean; isReady: boolean;
@@ -36,27 +54,32 @@ declare const WPP: {
sendTextMessage( sendTextMessage(
chatId: string, chatId: string,
text: string, text: string,
options?: { options?: SendMessageOptions,
delay?: number;
detectMentioned?: boolean;
markIsRead?: boolean;
mentionedList?: string[];
messageId?: string;
quotedMsg?: string;
quotedMsgPayload?: string;
waitForAck?: boolean;
},
): Promise<any>; ): Promise<any>;
sendFileMessage(
chatId: string,
data: string,
options?: SendMessageOptions,
): Promise<any>;
downloadMedia(msgId: string): Promise<Blob>;
on(event: string, callback: (...args: any[]) => void): void; on(event: string, callback: (...args: any[]) => void): void;
}; };
contact: { contact: {
getAllContacts(): Promise<any[]>; list(): Promise<any[]>;
get(contactId: string): any | undefined; get(contactId: string): any | undefined;
}; };
group: { group: {
getAllGroups(): Promise<any[]>; getAllGroups(): Promise<any[]>;
}; };
util: {
blobToBase64: (blob: Blob) => Promise<string>;
};
on(event: string, callback: (...args: any[]) => void): void;
}; };
View File
+42
View File
@@ -0,0 +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(process.env.WS_URL);
await wa.connect();
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 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 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);
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-vega",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WhatsApp Bridge</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
{
"name": "ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@base-ui/react": "^1.8.0",
"@fontsource-variable/inter": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.102.8",
"class-variance-authority": "^0.7.1",
"cn": "^0.3.0",
"dayjs": "^1.11.23",
"lucide-react": "^1.46.0",
"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",
"zustand": "^5.0.15"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+4074
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"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"
}
+202
View File
@@ -0,0 +1,202 @@
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";
import type { ChatDTO, ContactDTO } from "@shared/dto";
import dayjs from "dayjs";
import {
FaceGrinningIcon,
ImageIcon,
PaperclipIcon,
PhoneIcon,
UsersIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
type Props = {
selected: string | null;
onSelect: (chat: string) => void;
};
export default function ChatsSection({ selected, onSelect }: Props) {
const [query, setQuery] = useState("");
const contacts = useContacts();
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 = 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);
}, [chatList, query]);
return (
<>
<div className="px-4 py-3">
<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">
<span className="text-primary">All</span>
<span>·</span>
<span>Unread</span>
<span>·</span>
<span>Groups</span>
</div> */}
<div className="flex-1 overflow-y-auto px-2 pb-3">
{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>
))
: null}
{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>
</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>
</button>
);
})}
</div>
</>
);
}
function ChatPreviewMessage({
data,
from,
}: {
data: ChatDTO["previewMessage"];
from?: ContactDTO | null;
}) {
if (!data) {
return null;
}
let caption = useMemo(() => {
if (data.type === "image") {
return data.caption || "Picture";
}
if (data.type === "document") {
return data.caption || "Document";
}
if (data.type === "call_log") {
return "Call log";
}
if (data.type === "sticker") {
return "Sticker";
}
return data.type === "chat" || data.caption
? data.caption || data.body
: null;
}, [data]);
return (
<>
{from ? (
<span className="whitespace-nowrap" title={getFormattedName(from)}>
{getFormattedName(from) + ": "}
</span>
) : null}
{data.type === "image" && <ImageIcon size={16} />}
{data.type === "document" && <PaperclipIcon size={16} />}
{data.type === "call_log" && <PhoneIcon size={16} />}
{data.type === "sticker" && <FaceGrinningIcon size={16} />}
<p className="truncate" title={caption || undefined}>
{caption?.substring(0, 50)}
</p>
</>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { createContext, useContext, useEffect, useRef, useState } from "react";
import type { WhatsAppClient } from "@client/whatsapp";
import { useQuery } from "@tanstack/react-query";
import wa from "../lib/wa-client";
export const WAContext = createContext<{
client: WhatsAppClient;
isConnected: boolean;
isReady: boolean;
addListener: (event: string, callback: (...args: any[]) => any) => () => void;
}>(null!);
export function useWAClient() {
const ctx = useContext(WAContext);
if (!ctx) throw new Error("useWAClient must be used within a WAProvider");
return ctx;
}
type ListenerMap = Record<string, ((...args: any[]) => any)[]>;
export function WAProvider({ children }: { children: React.ReactNode }) {
const isInitialized = useRef(false);
const eventListeners = useRef<ListenerMap>({});
const [isConnected, setConnected] = useState(false);
const { data: status } = useQuery({
queryKey: ["status"],
queryFn: () => wa.status(),
enabled: isConnected,
});
function addListener(event: string, callback: (...args: any[]) => any) {
eventListeners.current[event] = eventListeners.current[event] || [];
eventListeners.current[event].push(callback);
return () => {
eventListeners.current[event] = eventListeners.current[event].filter(
(i) => i !== callback,
);
};
}
function emit(event: string, ...args: any[]) {
eventListeners.current[event]?.forEach((i) => i(...args));
}
useEffect(() => {
if (isInitialized.current) return;
wa.connect();
isInitialized.current = true;
wa.on("status", setConnected);
wa.on("message", (data) => emit("message", data));
}, []);
return (
<WAContext.Provider
value={{
client: wa,
isConnected,
isReady: status?.authenticated && status?.ready ? true : false,
addListener,
}}
>
{children}
</WAContext.Provider>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useMemo, useRef } from "react";
import { useWAClient } from "./context";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Select,
SelectContent,
SelectItem,
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({
queryKey: ["devices"],
queryFn: () => client.listDevices(),
enabled: isReady,
refetchInterval: 10_000,
});
useEffect(() => {
if (!curDeviceId && devices?.length && devices[0].id) {
setDeviceId(devices[0].id);
}
}, [curDeviceId, devices]);
const deviceList = useMemo(() => {
return devices?.map((i, idx) => {
const lastAlive = i.lastAlive || 0;
const isOnline = Date.now() - lastAlive <= 60_000;
return {
label: i.name || i.id || "Unknown",
value: i.id || String(idx),
lastAlive,
isOnline,
};
});
}, [devices]);
const curDevice = useMemo(() => {
return deviceList?.find((i) => i.value === curDeviceId);
}, [deviceList, curDeviceId]);
return (
<div className="border-t p-4 flex items-center gap-2">
<Select
items={deviceList}
value={curDeviceId}
onValueChange={(i) => setDeviceId(i || "")}
>
<SelectTrigger className="w-full justify-start">
<SelectValue placeholder="Select Device.." className="flex-none" />
{curDevice ? (
<span
className={
"inline-block size-3 rounded-full " +
(curDevice.isOnline ? "bg-green-500" : "bg-red-500")
}
/>
) : null}
<div className="flex-1" />
</SelectTrigger>
<SelectContent>
{deviceList?.map((i) => (
<SelectItem
key={i.value}
value={i.value}
className="cursor-pointer"
>
<div className="flex items-center gap-2">
<p>{i.label}</p>
<span
className={
"inline-block size-3 rounded-full " +
(i.isOnline ? "bg-green-500" : "bg-red-500")
}
/>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<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>
);
}
+800
View File
@@ -0,0 +1,800 @@
import { useWAClient } from "./context";
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, useRef, useState } from "react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import MessageMedia from "@/components/message-media";
import { useContacts } from "@/hooks/useContact";
import { useMessageEvent } from "@/hooks/useMessageEvent";
import { useHashRouter } from "@/hooks/useHashRouter";
import { useChats } from "@/hooks/useChats";
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);
type Props = {
chatId: string | null;
maxCount?: number;
};
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,
isLoading,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["messages", chatId],
queryFn: ({ pageParam }) =>
client
.getMessages({
chatId: chatId!,
count: maxCount,
direction: "before",
id: pageParam,
})
.then((i) => i.reverse()),
initialPageParam: undefined as string | undefined,
enabled: Boolean(chatId && isReady),
getNextPageParam: (lastPage) => {
if (lastPage.length < maxCount) return undefined;
return getId(lastPage[lastPage.length - 1]?.id);
},
select: (data) => data.pages.flat(),
});
useEffect(() => {
if (chatId) {
msgContainerRef.current.scrollTop = 0;
setSelectedIds(null);
}
}, [chatId]);
useMessageEvent((data) => {
if (data.chatId !== chatId) return;
queryClient.setQueryData(["messages", chatId], (old: any) => {
if (!old) return old;
return {
...old,
pages: [[data, ...old.pages[0]], ...old.pages.slice(1)],
};
});
});
const endScrollRef = useIntersection(() => {
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(
() => chats?.find((c) => c.id === chatId),
[chats, chatId],
);
const messageList = useMemo(() => {
const q = search.trim().toLowerCase();
return messages
?.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),
);
const quotedMsg = i.quotedMsg
? {
...i.quotedMsg,
fromMe: i.fromMe,
from: contacts?.find((c) => {
return (
getId(c.id) ===
getId(i.quotedMsg?.author || i.quotedMsg?.from)
);
}),
}
: null;
return {
...i,
id: getId(i.id),
from,
quotedMsg,
};
});
}, [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 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">
{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" />
<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>
<Avatar
name={chat?.formattedTitle || chat?.name}
className="size-10 -ml-2"
/>
<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>
)}
</header>
<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>
<span className="h-px flex-1 bg-border" />
</div> */}
{messageList?.map((item, index) => (
<div
id={item.id.replace(/^(?:false_|true_)/, "")}
key={`${item.timestamp}-${index}`}
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%]">
<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
</p>
) : null}
{/* <pre className="overflow-y-auto max-h-80">
{JSON.stringify(item, null, 2)}
</pre> */}
{item.quotedMsg &&
["chat", "image", "document"].includes(
item.quotedMsg.type as string,
) ? (
<QuotedMsg
data={item.quotedMsg as never}
getMentionLabel={getMentionLabel}
onMentionClick={onMentionClick}
/>
) : null}
{item.type === "image" ||
item.type === "sticker" ||
item.type === "document" ||
item.type === "ptt" ? (
<MessageMedia data={item as never} />
) : null}
{item.type === "chat" || item.caption ? (
<Paragraph
text={item.caption || item.body}
getMentionLabel={getMentionLabel}
onMentionClick={onMentionClick}
/>
) : null}
{item.type === "call_log" && (
<div className="flex items-center gap-2 pt-3 pb-2 px-2">
<PhoneCallIcon className="size-5" />
<p className="text-sm italic">Call log</p>
</div>
)}
<div
className={`mt-1 flex items-center justify-end gap-1 text-[10px] ${item.fromMe ? "text-primary" : "text-muted-foreground"}`}
>
<span>{dayjs((item.timestamp ?? 0) * 1000).fromNow()}</span>
{item.fromMe && (
<>
{item.ack && item.ack >= 2 ? (
<CheckCheckIcon
size={14}
className={item.ack === 3 ? "text-blue-300" : ""}
/>
) : (
<CheckIcon size={13} />
)}
</>
)}
</div>
</div>
</div>
</div>
))}
{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" />
) : null}
<span>
{isLoading || isFetchingNextPage
? "Loading..."
: "End of messages"}
</span>
</div>
<span className="h-px flex-1 bg-border" />
</div>
</div>
<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 }[]>([]);
const [isPending, setPending] = useState(false);
useEffect(() => {
function onDragOver(e: DragEvent) {
e.preventDefault();
}
function onDrop(e: DragEvent) {
const fileList = e.dataTransfer?.files || [];
if (fileList) {
e.preventDefault();
onFileAttached([...fileList]);
}
}
document.addEventListener("dragover", onDragOver);
document.addEventListener("drop", onDrop);
return () => {
document.removeEventListener("dragover", onDragOver);
document.removeEventListener("drop", onDrop);
};
}, []);
function onFileAttached(files: File[]) {
setFiles((f) => [...f, ...files.map((file) => ({ id: nanoid(), file }))]);
}
function onPaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
const fileList = e.clipboardData?.files || [];
if (fileList.length > 0) {
e.preventDefault();
onFileAttached([...fileList]);
}
}
async function sendMessage() {
const text = message.trim();
if ((!text && !files.length) || !chatId || !isReady) return;
try {
setPending(true);
const options = { waitForAck: true, quotedMsg: replyTo?.id || undefined };
if (files.length > 0) {
const fileList = await getFilesAsBase64(files.map((f) => f.file));
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
await client.sendFile({
chatId,
data: file,
options: {
...options,
caption: i === 0 ? text : undefined,
filename: files[i].file.name,
mimetype: files[i].file.type,
},
});
}
} else {
await client.sendText({ chatId, text, options });
}
setTimeout(refetch, 1000);
setFiles([]);
setMessage("");
setReplyTo?.(null);
} catch (err) {
alert((err as Error).message);
} finally {
setPending(false);
}
}
function onRemoveAttachment(id: string) {
setFiles((f) => f.filter((file) => file.id !== id));
}
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) => (
<AttachmentPreview
key={i.id}
file={i.file}
onRemove={() => onRemoveAttachment(i.id)}
/>
))}
</div>
)}
<div className="flex items-end gap-2">
<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>
<div className="flex min-h-10 flex-1 items-center gap-2 rounded-2xl bg-muted px-3">
{/* <button
aria-label="Add emoji"
className="shrink-0 text-muted-foreground hover:text-muted-foreground"
>
<SmileIcon size={19} />
</button> */}
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
onPaste={onPaste}
enterKeyHint="enter"
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing &&
event.keyCode !== 229
) {
event.preventDefault();
sendMessage();
}
}}
placeholder="Write a message..."
rows={1}
className="max-h-24 min-h-10 flex-1 resize-none field-sizing-content bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<button
aria-label="Send message"
onClick={sendMessage}
className="grid size-10 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!isReady || (!message.trim() && !files.length) || isPending}
>
<SendIcon size={17} />
</button>
</div>
</div>
);
}
function AttachmentPreview({
file,
onRemove,
}: {
file: File;
onRemove?: () => void;
}) {
const [img, setImg] = useState<string | null>(null);
useEffect(() => {
if (!file.type.startsWith("image/")) return;
const blob = URL.createObjectURL(file);
setImg(blob);
return () => URL.revokeObjectURL(blob);
}, [file]);
return (
<div
className={cn(
"flex items-center gap-2 rounded-2xl bg-muted px-3 py-2 shrink-0",
img != null && "rounded-sm",
)}
>
{img ? (
<a href={img} target="_blank" rel="noopener noreferrer">
<img
src={img}
alt={file.name}
className="h-10 aspect-video object-cover"
/>
</a>
) : (
<p className="text-xs">{file.name}</p>
)}
<button
aria-label="Remove attachment"
className="shrink-0 text-muted-foreground cursor-pointer hover:bg-muted hover:text-white"
onClick={onRemove}
>
<XIcon size={16} />
</button>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import ChatsSection from "./chats";
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 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 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">
<div className="grid size-10 place-items-center rounded-xl bg-accent text-accent-foreground">
<Sparkles aria-hidden="true" />
</div>
<div>
<h1 className="text-lg font-semibold tracking-tight">
Messages
</h1>
<p className="text-xs text-muted-foreground">Stay connected</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
aria-label="New message"
className="grid size-9 place-items-center rounded-full text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<Edit3 size={18} />
</button>
<button
aria-label="More options"
className="grid size-9 place-items-center rounded-full text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<EllipsisVertical size={19} />
</button>
</div>
</header> */}
<ChatsSection
selected={chatId}
onSelect={(id) =>
router.navigate(`/?chatId=${id}`, { replace: !!chatId })
}
/>
{/* <div className="border-t border-border px-5 py-4">
<div className="flex items-center gap-3">
<div className="grid size-9 place-items-center rounded-full bg-border text-xs font-semibold text-muted-foreground">
JD
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Jordan Davis</p>
<p className="truncate text-xs text-muted-foreground">
jordan@example.com
</p>
</div>
<button
aria-label="Account menu"
className="text-muted-foreground"
>
<ChevronDown size={17} />
</button>
</div>
</div> */}
<DevicesSection curDeviceId={curDeviceId} setDeviceId={setDeviceId} />
</aside>
{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>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { useWAClient } from "@/app/context";
import { storage } from "@/lib/storage";
import type { MessageDTO } from "@shared/dto";
import { cn } from "cn";
import {
DownloadIcon,
ImageIcon,
Loader2Icon,
PaperclipIcon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
type Props = {
data: Omit<MessageDTO, "id" | "from"> & { id: string };
};
export default function MessageMedia({ data }: Props) {
const { type } = data;
const { client } = useWAClient();
const loadingRef = useRef(false);
const [isLoading, setLoading] = useState(false);
const [isDownloaded, setDownloaded] = useState(false);
const [body, setBody] = useState(
type === "image" && data.body
? "data:image/webp;base64," + data.body
: data.body,
);
useEffect(() => {
if (loadingRef.current) return;
(async function () {
try {
loadingRef.current = true;
if (type === "image" || type === "document") {
const res = await storage.get<string>(data.id);
if (res) {
setBody(res);
setDownloaded(true);
}
}
if (type === "sticker" && !body) {
const res = await storage.get<string>(data.id);
if (res) {
setBody(res);
setDownloaded(true);
} else {
await fetchMedia();
}
}
} catch (err) {
console.error(err);
} finally {
loadingRef.current = false;
}
})();
}, [data.id]);
async function fetchMedia() {
try {
setLoading(true);
const res = await client.getMedia(data.id);
setBody(res);
setDownloaded(true);
storage.set(data.id, res);
return res;
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
async function openMedia() {
if (isLoading) return;
if (isDownloaded) {
// download image
const blob = await fetch(body!).then((res) => res.blob());
const url = URL.createObjectURL(blob);
window.open(url);
return;
} else {
fetchMedia();
}
}
if (type === "image") {
return (
<a
href="#"
onClick={(e) => {
e.preventDefault();
openMedia();
}}
className={cn(
"w-full min-h-32 max-h-40 aspect-video block relative overflow-hidden rounded",
)}
>
{body ? (
<img
src={body}
alt=""
className={cn(
"w-full h-full object-cover",
!isDownloaded && "blur-[2px]",
)}
/>
) : (
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
<ImageIcon className="size-8 opacity-80" />
</div>
)}
{isLoading && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<Loader2Icon className="animate-spin size-8" />
</div>
)}
</a>
);
}
if (type === "sticker" && body) {
return (
<img
src={body}
alt=""
className="w-full max-w-40 block relative overflow-hidden"
/>
);
}
if (type === "document" || type === "ptt") {
return (
<button
onClick={() => openMedia()}
className="flex gap-2 items-center rounded-md bg-accent px-3 py-2 mb-2 truncate w-full cursor-pointer"
disabled={isLoading}
>
{isLoading ? (
<Loader2Icon className="animate-spin size-4" />
) : isDownloaded ? (
<PaperclipIcon className="size-4" />
) : (
<DownloadIcon className="size-4" />
)}
<span className="text-xs truncate">
{type === "ptt" ? `Voice note (${data.duration}s)` : data.filename}
</span>
<span className="text-xs">{readableBytes(data.size || 0)}</span>
</button>
);
}
return null;
}
function readableBytes(bytes: number) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
if (bytes < 1024 * 1024 * 1024)
return (bytes / 1024 / 1024).toFixed(1) + " MB";
return (bytes / 1024 / 1024 / 1024).toFixed(1) + " GB";
}
+159
View File
@@ -0,0 +1,159 @@
import { cn } from "cn";
import React from "react";
type Token =
| { type: "text"; value: string }
| { type: "bold"; value: string }
| { type: "italic"; value: string }
| { type: "strike"; value: string }
| { type: "code"; value: string }
| { type: "url"; value: string }
| { type: "mention"; value: string };
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<]+|@\d+)/g;
const tokens: Token[] = [];
let lastIndex = 0;
for (const match of message.matchAll(regex)) {
const index = match.index ?? 0;
const value = match[0];
if (index > lastIndex) {
tokens.push({
type: "text",
value: message.slice(lastIndex, index),
});
}
if (value.startsWith("*")) {
tokens.push({ type: "bold", value: value.slice(1, -1) });
} else if (value.startsWith("_")) {
tokens.push({ type: "italic", value: value.slice(1, -1) });
} else if (value.startsWith("~")) {
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(/[.,!?;:)]+$/, "");
tokens.push({ type: "url", value: url });
// Preserve trailing punctuation.
if (url.length < value.length) {
tokens.push({
type: "text",
value: value.slice(url.length),
});
}
}
lastIndex = index + value.length;
}
if (lastIndex < message.length) {
tokens.push({
type: "text",
value: message.slice(lastIndex),
});
}
return tokens.map((token, i) => {
switch (token.type) {
case "bold":
return (
<strong key={i} className="font-bold">
{token.value}
</strong>
);
case "italic":
return (
<em key={i} className="italic">
{token.value}
</em>
);
case "strike":
return (
<del key={i} className="line-through">
{token.value}
</del>
);
case "code":
return (
<code
key={i}
className="rounded bg-black/10 px-1 py-0.5 font-mono text-sm"
>
{token.value}
</code>
);
case "url":
return (
<a
key={i}
href={
token.value.startsWith("www.")
? `https://${token.value}`
: token.value
}
target="_blank"
rel="noopener noreferrer"
className="break-all text-blue-200 underline hover:text-blue-300"
>
{token.value}
</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 = RenderOptions & {
text?: string | null;
className?: string;
};
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, 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>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { getInitial } from "@/lib/utils";
import { cn } from "cn";
import { UserIcon } from "lucide-react";
type Props = {
name?: string | null;
icon?: React.ReactNode;
className?: string;
iconClassName?: string;
};
const colorSchemes = [
"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) {
if (!name) return colorSchemes[0];
return colorSchemes[getInitial(name).charCodeAt(0) % colorSchemes.length];
}
export default function Avatar({
name,
icon,
className,
iconClassName,
}: Props) {
const color = getColorBasedOnName(name);
const initial = getInitial(name);
return (
<div
className={cn(
"relative grid size-11 shrink-0 place-items-center rounded-full text-sm font-semibold bg-rose-200 text-rose-800",
color,
className,
)}
>
{icon ? (
icon
) : initial ? (
initial
) : (
<UserIcon className={cn("size-4", iconClassName)} />
)}
</div>
);
}
+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 };
+57
View File
@@ -0,0 +1,57 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-9",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+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;
+199
View File
@@ -0,0 +1,199 @@
import * as React from "react";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { cn } from "cn";
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
);
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
);
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+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 };
+170
View File
@@ -0,0 +1,170 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: "Inter Variable", sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
::selection {
background: var(--accent);
color: var(--accent-foreground);
}
}
.message-pattern {
background-color: var(--muted);
background-image: radial-gradient(color-mix(in oklch, var(--border) 70%, transparent) 0.7px, transparent 0.7px);
background-size: 18px 18px;
}
@media (max-width: 767px) {
textarea {
font-size: 16px;
}
}
@layer utilities {
.dark * {
scrollbar-width: thin;
scrollbar-color: #4b5563 transparent;
}
.dark *::-webkit-scrollbar {
width: 6px;
}
.dark *::-webkit-scrollbar-track {
background: transparent;
}
.dark *::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 9999px;
}
.dark *::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { useWAClient } from "@/app/context";
import { getId } from "@/lib/utils";
import type { WhatsAppClient } from "@client/whatsapp";
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
async function getChats(client: WhatsAppClient) {
return client
.listChats({ limit: 500 })
.then((res) => res.map((i) => ({ ...i, id: getId(i.id) })));
}
type Response = Awaited<ReturnType<typeof getChats>>;
export function useChats(opt?: Partial<UseQueryOptions>) {
const { client, isReady } = useWAClient();
return useQuery<Response, Error>({
queryKey: ["chats"],
queryFn: () => getChats(client),
enabled: isReady,
...((opt || {}) as any),
});
}
+22
View File
@@ -0,0 +1,22 @@
import { useWAClient } from "@/app/context";
import { getId } from "@/lib/utils";
import type { ContactDTO } from "@shared/dto";
import { useQuery } from "@tanstack/react-query";
export function useContacts() {
const { client, isReady } = useWAClient();
const res = useQuery({
queryKey: ["contacts"],
queryFn: () => client.listContacts(),
refetchOnWindowFocus: false,
enabled: isReady,
});
return {
...res,
findById(id: ContactDTO["id"]) {
return res.data?.find((i) => getId(i.id) === getId(id));
},
};
}
+10
View File
@@ -0,0 +1,10 @@
import { useRef } from "react";
export function useDebounceFn(delay: number = 500) {
const timeoutRef = useRef<any>(null);
return (fn: any, delay2?: number) => {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(fn, delay2 || delay);
};
}
+111
View File
@@ -0,0 +1,111 @@
import { useCallback, useEffect, useState } from "react";
type HashLocation = {
path: string;
query: URLSearchParams;
search: string;
};
type HashRouter = HashLocation & {
navigate: (path: string, options?: NavigateOptions) => void;
push: (path: string) => void;
reset: (path?: string) => void;
canGoback: () => boolean;
goBack: () => void;
};
type NavigateOptions = {
replace?: boolean;
};
function getHashLocation(): HashLocation {
const hash = window.location.hash.slice(1);
const [pathname, search = ""] = hash.split("?");
const path = pathname
? pathname.startsWith("/")
? pathname
: `/${pathname}`
: "/";
return {
path,
search: search ? `?${search}` : "",
query: new URLSearchParams(search),
};
}
function normalizePath(path: string) {
return path.startsWith("/") ? path : `/${path}`;
}
function setHashPath(path: string, replace = false) {
const normalized = normalizePath(path);
const hash = `#${normalized}`;
if (replace) {
window.history.replaceState(null, "", hash);
// replaceState doesn't emit hashchange
window.dispatchEvent(new HashChangeEvent("hashchange"));
} else {
window.location.hash = normalized;
}
}
export function useHashRouter(): HashRouter {
const [location, setLocation] = useState<HashLocation>(getHashLocation);
useEffect(() => {
const handleChange = () => {
setLocation(getHashLocation());
};
window.addEventListener("hashchange", handleChange);
return () => {
window.removeEventListener("hashchange", handleChange);
};
}, []);
const navigate = useCallback(
(path: string, options: NavigateOptions = {}) => {
setHashPath(path, options.replace);
},
[],
);
const push = useCallback(
(path: string) => {
navigate(path);
},
[navigate],
);
const reset = useCallback(
(path = "/") => {
navigate(path, { replace: true });
},
[navigate],
);
const canGoback = useCallback(() => window.history.length > 1, []);
const goBack = useCallback(() => {
if (canGoback()) {
window.history.back();
} else {
reset();
}
}, [canGoback, reset]);
return {
...location,
navigate,
push,
reset,
canGoback,
goBack,
};
}
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useRef } from "react";
export function useIntersection(
callback: (entry: IntersectionObserverEntry) => void,
options: any = {},
) {
const ref = useRef(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
callback(entry);
}
}, options);
observer.observe(element);
return () => observer.disconnect();
}, [callback, options]);
return ref;
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
export function useLocalStorage<T = unknown>(key: string, initialValue: T) {
const [state, setState] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.log(error);
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(state));
} catch (error) {
console.log(error);
}
}, [state]);
return [state, setState] as const;
}
+10
View File
@@ -0,0 +1,10 @@
import { useWAClient } from "@/app/context";
import type { MessageDTO } from "@shared/dto";
import { useEffect } from "react";
export function useMessageEvent(callback: (msg: MessageDTO) => any) {
const { addListener } = useWAClient();
useEffect(() => {
return addListener("message", callback);
}, [callback]);
}
+124
View File
@@ -0,0 +1,124 @@
export interface StorageItem<T = unknown> {
id: string;
data: T;
}
export class Storage<T = unknown> {
private db: IDBDatabase | null = null;
private dbName = "";
private storeName = "";
private version = 1;
constructor(dbName = "app-storage", storeName = "data", version = 1) {
this.dbName = dbName;
this.storeName = storeName;
this.version = version;
}
private async getDb(): Promise<IDBDatabase> {
if (this.db) return this.db;
this.db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, {
keyPath: "id",
});
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return this.db;
}
async set(id: string, data: T): Promise<void> {
const db = await this.getDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(this.storeName, "readwrite");
const store = tx.objectStore(this.storeName);
store.put({ id, data });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
async get<T = any>(id: string): Promise<T | undefined> {
const db = await this.getDb();
return new Promise<T | undefined>((resolve, reject) => {
const tx = db.transaction(this.storeName, "readonly");
const request = tx.objectStore(this.storeName).get(id);
request.onsuccess = () => {
const item = request.result as StorageItem<T> | undefined;
resolve(item?.data);
};
request.onerror = () => reject(request.error);
});
}
async has(id: string): Promise<boolean> {
const db = await this.getDb();
return new Promise<boolean>((resolve, reject) => {
const tx = db.transaction(this.storeName, "readonly");
const request = tx.objectStore(this.storeName).count(id);
request.onsuccess = () => resolve(request.result > 0);
request.onerror = () => reject(request.error);
});
}
async delete(id: string): Promise<void> {
const db = await this.getDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(this.storeName, "readwrite");
tx.objectStore(this.storeName).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
async clear(): Promise<void> {
const db = await this.getDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(this.storeName, "readwrite");
tx.objectStore(this.storeName).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
async keys(): Promise<string[]> {
const db = await this.getDb();
return new Promise<string[]>((resolve, reject) => {
const tx = db.transaction(this.storeName, "readonly");
const request = tx.objectStore(this.storeName).getAllKeys();
request.onsuccess = () => resolve(request.result as string[]);
request.onerror = () => reject(request.error);
});
}
}
export const storage = new Storage();
+71
View File
@@ -0,0 +1,71 @@
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) {
if (!id) return "";
return typeof id === "string" ? id : (id._serialized ?? "");
}
export function getInitial(name?: string | null) {
return (
name
?.replace("dr.", "")
.replace(/[^a-zA-Z ]/g, "")
.toUpperCase()
.trim()
.split(" ")
.map((i) => i[0])
.slice(0, 2)
.join("") || ""
);
}
export function getFormattedName(
name: string | SerializedId | ContactDTO | null | undefined,
): string {
if (!name) return "";
return typeof name === "string"
? name
: typeof name.formattedName === "string"
? name.formattedName
: "user" in name
? name.user || ""
: "";
}
export async function getFilesAsBase64(files: File[]) {
return Promise.all(
files.map((file) => {
return new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve((reader.result as string) || "");
reader.readAsDataURL(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;
}
}
+5
View File
@@ -0,0 +1,5 @@
import { WhatsAppClient } from "@client/whatsapp";
const wa = new WhatsAppClient(import.meta.env.VITE_WHATSAPP_URL);
export default wa;
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WAProvider } from "./app/context.tsx";
import App from "./app/page.tsx";
import "./globals.css";
const queryCLient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryCLient}>
<WAProvider>
<App />
</WAProvider>
</QueryClientProvider>
</StrictMode>,
);
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["../src/shared/*"],
"@client/*": ["../src/client/*"]
}
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["../src/shared/*"],
"@client/*": ["../src/client/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import path from "path";
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"),
"@shared": path.resolve(import.meta.dirname, "../src/shared"),
"@client": path.resolve(import.meta.dirname, "../src/client"),
},
},
});