Compare commits
35
Commits
3b589578af
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
166c96caf2 | ||
|
|
b7bbce7374 | ||
|
|
21c30393fc | ||
|
|
999ecc1a61 | ||
|
|
495ee9c040 | ||
|
|
6513dc39b6 | ||
|
|
0ece6bbb63 | ||
|
|
07dfb7d443 | ||
|
|
c9c55f56c8 | ||
|
|
c2af4f9f6b | ||
|
|
088e5e29f8 | ||
|
|
4d70805581 | ||
|
|
edf28006b2 | ||
|
|
7b0de7056d | ||
|
|
13989c0702 | ||
|
|
be41a5b188 | ||
|
|
89dfd21adb | ||
|
|
8aa10d38b1 | ||
|
|
d9e627cc5f | ||
|
|
9792e49123 | ||
|
|
6e852ab224 | ||
|
|
65a2e534d7 | ||
|
|
e9c1d5f3c7 | ||
|
|
aa25af028e | ||
|
|
de0666beb1 | ||
|
|
e3d55e4b7e | ||
|
|
3fe95e925e | ||
|
|
d45c78a5f8 | ||
|
|
19b69c7a51 | ||
|
|
881ce919aa | ||
|
|
78faf2b070 | ||
|
|
d673055210 | ||
|
|
9b59c1f127 | ||
|
|
d0edccf15d | ||
|
|
501ee73e41 |
Vendored
+50
-62
@@ -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
|
||||||
};
|
};
|
||||||
@@ -199,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,
|
||||||
@@ -228,8 +230,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// src/userscript/index.ts
|
// src/userscript/index.ts
|
||||||
var WS_URL = "wss://wa-bridge.rul.sh/ws";
|
|
||||||
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");
|
||||||
@@ -251,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)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -260,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({
|
||||||
@@ -277,7 +295,7 @@
|
|||||||
}
|
}
|
||||||
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, {
|
||||||
@@ -287,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;
|
||||||
}
|
}
|
||||||
@@ -410,6 +397,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
init();
|
||||||
start();
|
start();
|
||||||
})();
|
})();
|
||||||
//# sourceMappingURL=whatsapp-bridge.user.js.map
|
//# sourceMappingURL=whatsapp-bridge.user.js.map
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
+18
-1
@@ -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
@@ -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": {
|
||||||
|
|||||||
+40
-8
@@ -1,7 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
GetMessagesParams,
|
GetMessagesParams,
|
||||||
ListChatsParams,
|
ListChatsParams,
|
||||||
SearchMessagesParams,
|
SendFileParams,
|
||||||
SendTextParams,
|
SendTextParams,
|
||||||
} from "../shared/protocol";
|
} from "../shared/protocol";
|
||||||
import type {
|
import type {
|
||||||
@@ -16,20 +16,26 @@ export class WhatsAppClient {
|
|||||||
|
|
||||||
private url: string = "";
|
private url: string = "";
|
||||||
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;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
private listeners: Map<string, (...args: any[]) => void> = new Map();
|
private listeners: Map<string, (...args: any[]) => void> = new Map();
|
||||||
|
|
||||||
constructor(url = "ws://127.0.0.1:8787") {
|
constructor(url = "ws://127.0.0.1:8787", deviceId?: string) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeviceId(id?: string | null) {
|
||||||
|
this.deviceId = id || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
@@ -48,6 +54,12 @@ export class WhatsAppClient {
|
|||||||
|
|
||||||
this.socket.addEventListener("message", (data) => {
|
this.socket.addEventListener("message", (data) => {
|
||||||
const message = JSON.parse(data.data);
|
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;
|
||||||
}
|
}
|
||||||
@@ -77,12 +89,17 @@ export class WhatsAppClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private request<T>(method: string, params?: unknown): Promise<T> {
|
private request<T>(
|
||||||
|
method: string,
|
||||||
|
params?: unknown,
|
||||||
|
devId?: string | null,
|
||||||
|
): Promise<T> {
|
||||||
if (!this.isConnected || !this.socket) {
|
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(() => {
|
||||||
@@ -100,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,
|
||||||
@@ -127,6 +145,12 @@ export class WhatsAppClient {
|
|||||||
}>("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);
|
||||||
}
|
}
|
||||||
@@ -143,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
+127
-43
@@ -3,6 +3,7 @@ import { WebSocketServer, WebSocket } from "ws";
|
|||||||
import type { BridgeRequest, RpcResponse } from "../shared/protocol";
|
import type { BridgeRequest, RpcResponse } from "../shared/protocol";
|
||||||
import { initDb, insertMessage } from "./db";
|
import { initDb, insertMessage } from "./db";
|
||||||
import type { MessageDTO } from "../shared/dto";
|
import type { MessageDTO } from "../shared/dto";
|
||||||
|
import cacheMgr from "./lib/cache";
|
||||||
|
|
||||||
const PORT = 8787;
|
const PORT = 8787;
|
||||||
|
|
||||||
@@ -27,23 +28,45 @@ function requestId() {
|
|||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWhatsApp(): WebSocket | undefined {
|
function getDevices() {
|
||||||
for (const ws of bridgeSockets) {
|
return [
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
...new Map(
|
||||||
return ws;
|
[...bridgeSockets].reverse().map((ws) => [
|
||||||
}
|
ws.deviceId,
|
||||||
|
{
|
||||||
|
id: ws.deviceId,
|
||||||
|
name: ws.deviceName,
|
||||||
|
lastAlive: ws.lastAlive,
|
||||||
|
lastSuccessfulCall: ws.lastSuccessfulCall,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
bridgeSockets.delete(ws);
|
function getWhatsApp(deviceId?: string): WebSocket | null {
|
||||||
|
if (deviceId) {
|
||||||
|
for (const ws of [...bridgeSockets].reverse()) {
|
||||||
|
if (ws.readyState === WebSocket.OPEN && ws.deviceId === deviceId) {
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
// 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> {
|
||||||
const wa = getWhatsApp();
|
|
||||||
if (!wa || wa.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");
|
||||||
}
|
}
|
||||||
@@ -74,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) {
|
||||||
@@ -92,31 +133,49 @@ 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") {
|
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
|
// forward to clients
|
||||||
clientSockets.forEach((ws) => {
|
clientSockets.forEach((ws) => {
|
||||||
ws.send(JSON.stringify(message));
|
ws.send(JSON.stringify(message));
|
||||||
});
|
});
|
||||||
|
|
||||||
const m = message.data as MessageDTO;
|
|
||||||
|
|
||||||
insertMessage({
|
insertMessage({
|
||||||
id: m.id.id || m.id._serialized || "",
|
id: msgId,
|
||||||
chatId: m.chatId || "",
|
chatId: m.chatId || "",
|
||||||
from: typeof m.from === "string" ? m.from : m.from?._serialized || "",
|
from: typeof m.from === "string" ? m.from : m.from?._serialized || "",
|
||||||
to: typeof m.to === "string" ? m.to : m.to?._serialized || "",
|
to: typeof m.to === "string" ? m.to : m.to?._serialized || "",
|
||||||
@@ -128,26 +187,42 @@ function handleWhatsAppMessage(data: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wss.on("connection", (ws) => {
|
wss.on("connection", (ws) => {
|
||||||
console.log("[WS] socket connected");
|
// console.log("[WS] socket connected");
|
||||||
|
|
||||||
const url = new URL(ws.url ?? "", "http://localhost");
|
const url = new URL(ws.url ?? "", "http://localhost");
|
||||||
const isBridge =
|
const isBridge =
|
||||||
!url.searchParams.get("client") || url.searchParams.get("bridge") === "1";
|
!url.searchParams.get("client") || url.searchParams.get("bridge") === "1";
|
||||||
|
|
||||||
|
const ping = setInterval(() => {
|
||||||
|
ws.send(JSON.stringify({ type: "ping" }));
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
const clean = () => {
|
||||||
|
clearInterval(ping);
|
||||||
|
};
|
||||||
|
|
||||||
if (isBridge) {
|
if (isBridge) {
|
||||||
|
ws.lastAlive = Date.now();
|
||||||
bridgeSockets.add(ws);
|
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", () => {
|
||||||
|
clean();
|
||||||
bridgeSockets.delete(ws);
|
bridgeSockets.delete(ws);
|
||||||
console.log("[WS] WhatsApp bridge disconnected");
|
|
||||||
|
console.log("[WS] WhatsApp bridge disconnected", {
|
||||||
|
deviceId: ws.deviceId,
|
||||||
|
deviceName: ws.deviceName,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("error", () => {
|
ws.on("error", () => {
|
||||||
|
clean();
|
||||||
bridgeSockets.delete(ws);
|
bridgeSockets.delete(ws);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -156,7 +231,7 @@ wss.on("connection", (ws) => {
|
|||||||
clientSockets.add(ws);
|
clientSockets.add(ws);
|
||||||
|
|
||||||
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;
|
||||||
@@ -165,40 +240,46 @@ wss.on("connection", (ws) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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",
|
||||||
@@ -223,14 +304,17 @@ wss.on("connection", (ws) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
|
clean();
|
||||||
clientSockets.delete(ws);
|
clientSockets.delete(ws);
|
||||||
console.log("[WS] external client disconnected");
|
// console.log("[WS] external client disconnected");
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("error", () => {
|
ws.on("error", () => {
|
||||||
|
clean();
|
||||||
clientSockets.delete(ws);
|
clientSockets.delete(ws);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
initDb();
|
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}`);
|
||||||
|
|||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import "ws";
|
||||||
|
|
||||||
|
declare module "ws" {
|
||||||
|
interface WebSocket {
|
||||||
|
isAuthenticated?: boolean;
|
||||||
|
deviceId?: string;
|
||||||
|
deviceName?: string;
|
||||||
|
lastAlive?: number;
|
||||||
|
lastSuccessfulCall?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,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 {
|
||||||
|
|||||||
+21
-13
@@ -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>;
|
||||||
|
|||||||
+55
-78
@@ -7,9 +7,22 @@ import {
|
|||||||
serializeMessage,
|
serializeMessage,
|
||||||
} from "./serializer";
|
} from "./serializer";
|
||||||
|
|
||||||
const WS_URL = "wss://wa-bridge.rul.sh/ws?bridge=1";
|
|
||||||
|
|
||||||
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": {
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -254,4 +230,5 @@ function start() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
start();
|
start();
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -267,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.id?.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,
|
||||||
|
|||||||
Vendored
+31
-10
@@ -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,18 +54,17 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,5 +77,9 @@ declare const WPP: {
|
|||||||
getAllGroups(): Promise<any[]>;
|
getAllGroups(): Promise<any[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
util: {
|
||||||
|
blobToBase64: (blob: Blob) => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
on(event: string, callback: (...args: any[]) => void): void;
|
on(event: string, callback: (...args: any[]) => void): void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
+5
-2
@@ -3,9 +3,12 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ui</title>
|
<title>WhatsApp Bridge</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+5
-1
@@ -16,12 +16,16 @@
|
|||||||
"@tanstack/react-query": "^5.102.8",
|
"@tanstack/react-query": "^5.102.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cn": "^0.3.0",
|
"cn": "^0.3.0",
|
||||||
|
"dayjs": "^1.11.23",
|
||||||
"lucide-react": "^1.46.0",
|
"lucide-react": "^1.46.0",
|
||||||
|
"nanoid": "^6.0.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
"react-select": "^5.10.2",
|
||||||
"shadcn": "^4.21.0",
|
"shadcn": "^4.21.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zustand": "^5.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
|
|||||||
Generated
+360
-4
@@ -26,24 +26,36 @@ importers:
|
|||||||
cn:
|
cn:
|
||||||
specifier: ^0.3.0
|
specifier: ^0.3.0
|
||||||
version: 0.3.0
|
version: 0.3.0
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.23
|
||||||
|
version: 1.11.23
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.46.0
|
specifier: ^1.46.0
|
||||||
version: 1.46.0([email protected])
|
version: 1.46.0([email protected])
|
||||||
|
nanoid:
|
||||||
|
specifier: ^6.0.1
|
||||||
|
version: 6.0.1
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.8
|
specifier: ^19.2.8
|
||||||
version: 19.3.0
|
version: 19.3.0
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.8
|
specifier: ^19.2.8
|
||||||
version: 19.3.0([email protected])
|
version: 19.3.0([email protected])
|
||||||
|
react-select:
|
||||||
|
specifier: ^5.10.2
|
||||||
|
version: 5.10.2(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
shadcn:
|
shadcn:
|
||||||
specifier: ^4.21.0
|
specifier: ^4.21.0
|
||||||
version: 4.21.0([email protected])
|
version: 4.21.0([email protected])([email protected])
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.3.3
|
specifier: ^4.3.3
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
zustand:
|
||||||
|
specifier: ^5.0.15
|
||||||
|
version: 5.0.15(@types/[email protected])([email protected])([email protected]([email protected]))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.13.3
|
specifier: ^24.13.3
|
||||||
@@ -236,6 +248,47 @@ packages:
|
|||||||
'@dotenvx/[email protected]':
|
'@dotenvx/[email protected]':
|
||||||
resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==}
|
resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==}
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==}
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==}
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: '>=16.8.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==}
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==}
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==}
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==}
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==}
|
||||||
|
|
||||||
'@floating-ui/[email protected]':
|
'@floating-ui/[email protected]':
|
||||||
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
||||||
|
|
||||||
@@ -619,11 +672,19 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==}
|
resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
|
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^19.3.0
|
'@types/react': ^19.3.0
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
|
resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
|
||||||
|
|
||||||
@@ -692,6 +753,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
|
resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
|
||||||
engines: {node: '>=10.12.0'}
|
engines: {node: '>=10.12.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
|
||||||
|
engines: {node: '>=10', npm: '>=6'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@@ -797,6 +862,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
|
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
@@ -812,6 +880,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==}
|
resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -833,6 +905,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
|
resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -886,6 +961,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
|
resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -945,6 +1023,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
|
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -1014,6 +1096,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||||
engines: {node: '>= 18.0.0'}
|
engines: {node: '>= 18.0.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
|
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1088,6 +1173,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==}
|
resolution: {integrity: sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
@@ -1130,6 +1218,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1413,6 +1505,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
|
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
@@ -1432,6 +1528,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
|
resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
|
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1482,6 +1581,11 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==}
|
||||||
|
engines: {node: ^22 || ^24 || >=26}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==}
|
resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1593,9 +1697,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
|
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -1639,6 +1750,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==}
|
resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -1663,6 +1777,21 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.3.0
|
react: ^19.3.0
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.6.0'
|
||||||
|
react-dom: '>=16.6.0'
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
|
resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1682,6 +1811,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1783,6 +1917,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1823,6 +1961,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
|
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-/NXbMVASt2UbSVgmWto4aBTIAeuYY7zOELpZybmNxqpsrbo5uYHpuEPf5nkyrPng0uG5YOb+Yv6s0FyKY4mDQg==}
|
resolution: {integrity: sha512-/NXbMVASt2UbSVgmWto4aBTIAeuYY7zOELpZybmNxqpsrbo5uYHpuEPf5nkyrPng0uG5YOb+Yv6s0FyKY4mDQg==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@@ -1898,6 +2043,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
browserslist: '>= 4.21.0'
|
browserslist: '>= 4.21.0'
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
|
resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1977,6 +2131,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==}
|
resolution: {integrity: sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==}
|
||||||
engines: {node: '>=18.19'}
|
engines: {node: '>=18.19'}
|
||||||
@@ -1993,6 +2151,24 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=18.0.0'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=18.0.0'
|
||||||
|
use-sync-external-store: '>=1.2.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
use-sync-external-store:
|
||||||
|
optional: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@babel/[email protected]':
|
'@babel/[email protected]':
|
||||||
@@ -2227,6 +2403,70 @@ snapshots:
|
|||||||
|
|
||||||
'@dotenvx/[email protected]': {}
|
'@dotenvx/[email protected]': {}
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-module-imports': 7.29.7
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
'@emotion/hash': 0.9.2
|
||||||
|
'@emotion/memoize': 0.9.0
|
||||||
|
'@emotion/serialize': 1.3.3
|
||||||
|
babel-plugin-macros: 3.1.0
|
||||||
|
convert-source-map: 1.9.0
|
||||||
|
escape-string-regexp: 4.0.0
|
||||||
|
find-root: 1.1.0
|
||||||
|
source-map: 0.5.7
|
||||||
|
stylis: 4.2.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@emotion/memoize': 0.9.0
|
||||||
|
'@emotion/sheet': 1.4.0
|
||||||
|
'@emotion/utils': 1.4.2
|
||||||
|
'@emotion/weak-memoize': 0.4.0
|
||||||
|
stylis: 4.2.0
|
||||||
|
|
||||||
|
'@emotion/[email protected]': {}
|
||||||
|
|
||||||
|
'@emotion/[email protected]': {}
|
||||||
|
|
||||||
|
'@emotion/[email protected](@types/[email protected])([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
'@emotion/babel-plugin': 11.13.5
|
||||||
|
'@emotion/cache': 11.14.0
|
||||||
|
'@emotion/serialize': 1.3.3
|
||||||
|
'@emotion/use-insertion-effect-with-fallbacks': 1.2.0([email protected])
|
||||||
|
'@emotion/utils': 1.4.2
|
||||||
|
'@emotion/weak-memoize': 0.4.0
|
||||||
|
hoist-non-react-statics: 3.3.2
|
||||||
|
react: 19.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.3.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@emotion/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@emotion/hash': 0.9.2
|
||||||
|
'@emotion/memoize': 0.9.0
|
||||||
|
'@emotion/unitless': 0.10.0
|
||||||
|
'@emotion/utils': 1.4.2
|
||||||
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@emotion/[email protected]': {}
|
||||||
|
|
||||||
|
'@emotion/[email protected]': {}
|
||||||
|
|
||||||
|
'@emotion/[email protected]([email protected])':
|
||||||
|
dependencies:
|
||||||
|
react: 19.3.0
|
||||||
|
|
||||||
|
'@emotion/[email protected]': {}
|
||||||
|
|
||||||
|
'@emotion/[email protected]': {}
|
||||||
|
|
||||||
'@floating-ui/[email protected]':
|
'@floating-ui/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/utils': 0.2.12
|
'@floating-ui/utils': 0.2.12
|
||||||
@@ -2498,10 +2738,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@types/[email protected](@types/[email protected])':
|
'@types/[email protected](@types/[email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.3.0
|
'@types/react': 19.3.0
|
||||||
|
|
||||||
|
'@types/[email protected](@types/[email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.3.0
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
@@ -2547,6 +2793,12 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
cosmiconfig: 7.1.0
|
||||||
|
resolve: 1.22.12
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -2644,6 +2896,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -2655,6 +2909,14 @@ snapshots:
|
|||||||
object-assign: 4.1.1
|
object-assign: 4.1.1
|
||||||
vary: 1.1.2
|
vary: 1.1.2
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
'@types/parse-json': 4.0.2
|
||||||
|
import-fresh: 3.3.1
|
||||||
|
parse-json: 5.2.0
|
||||||
|
path-type: 4.0.0
|
||||||
|
yaml: 1.10.3
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
env-paths: 2.2.1
|
env-paths: 2.2.1
|
||||||
@@ -2674,6 +2936,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
mimic-fn: 3.1.0
|
mimic-fn: 3.1.0
|
||||||
@@ -2682,7 +2946,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]([email protected]):
|
||||||
|
optionalDependencies:
|
||||||
|
babel-plugin-macros: 3.1.0
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
@@ -2703,6 +2969,11 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
csstype: 3.2.3
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-obj: 2.0.0
|
is-obj: 2.0.0
|
||||||
@@ -2751,6 +3022,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -2868,6 +3141,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
locate-path: 3.0.0
|
locate-path: 3.0.0
|
||||||
@@ -2934,6 +3209,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
react-is: 16.13.1
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -2967,6 +3246,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
hasown: 2.0.4
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3159,6 +3442,10 @@ snapshots:
|
|||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
is-unicode-supported: 1.3.0
|
is-unicode-supported: 1.3.0
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
@@ -3175,6 +3462,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3208,6 +3497,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
content-type: 2.1.0
|
content-type: 2.1.0
|
||||||
@@ -3327,8 +3618,12 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3365,6 +3660,12 @@ snapshots:
|
|||||||
kleur: 3.0.3
|
kleur: 3.0.3
|
||||||
sisteransi: 1.0.5
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
loose-envify: 1.4.0
|
||||||
|
object-assign: 4.1.1
|
||||||
|
react-is: 16.13.1
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
@@ -3391,6 +3692,34 @@ snapshots:
|
|||||||
react: 19.3.0
|
react: 19.3.0
|
||||||
scheduler: 0.28.0
|
scheduler: 0.28.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected](@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
'@emotion/cache': 11.14.0
|
||||||
|
'@emotion/react': 11.14.0(@types/[email protected])([email protected])
|
||||||
|
'@floating-ui/dom': 1.8.0
|
||||||
|
'@types/react-transition-group': 4.4.12(@types/[email protected])
|
||||||
|
memoize-one: 6.0.0
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 19.3.0
|
||||||
|
react-dom: 19.3.0([email protected])
|
||||||
|
react-transition-group: 4.4.5([email protected]([email protected]))([email protected])
|
||||||
|
use-isomorphic-layout-effect: 1.2.1(@types/[email protected])([email protected])
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
[email protected]([email protected]([email protected]))([email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
dom-helpers: 5.2.1
|
||||||
|
loose-envify: 1.4.0
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 19.3.0
|
||||||
|
react-dom: 19.3.0([email protected])
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -3407,6 +3736,13 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
is-core-module: 2.17.0
|
||||||
|
path-parse: 1.0.7
|
||||||
|
supports-preserve-symlinks-flag: 1.0.0
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
onetime: 7.0.0
|
onetime: 7.0.0
|
||||||
@@ -3486,7 +3822,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected])([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
'@babel/parser': 7.29.8
|
'@babel/parser': 7.29.8
|
||||||
@@ -3499,7 +3835,7 @@ snapshots:
|
|||||||
cn: 0.2.6
|
cn: 0.2.6
|
||||||
commander: 14.0.3
|
commander: 14.0.3
|
||||||
cosmiconfig: 9.0.2([email protected])
|
cosmiconfig: 9.0.2([email protected])
|
||||||
dedent: 1.7.2
|
dedent: 1.7.2([email protected])
|
||||||
deepmerge: 4.3.1
|
deepmerge: 4.3.1
|
||||||
diff: 8.0.4
|
diff: 8.0.4
|
||||||
execa: 9.6.1
|
execa: 9.6.1
|
||||||
@@ -3576,6 +3912,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3608,6 +3946,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3666,6 +4008,12 @@ snapshots:
|
|||||||
escalade: 3.2.0
|
escalade: 3.2.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
|
[email protected](@types/[email protected])([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 19.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.3.0
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.3.0
|
react: 19.3.0
|
||||||
@@ -3705,6 +4053,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
yoctocolors: 2.2.0
|
yoctocolors: 2.2.0
|
||||||
@@ -3716,3 +4066,9 @@ snapshots:
|
|||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected](@types/[email protected])([email protected])([email protected]([email protected])):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.3.0
|
||||||
|
react: 19.3.0
|
||||||
|
use-sync-external-store: 1.7.0([email protected])
|
||||||
|
|||||||
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 |
@@ -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"
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Button } from "./components/ui/button";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useWAClient } from "./lib/context";
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const { client, isReady } = useWAClient();
|
|
||||||
const { data: chats } = useQuery({
|
|
||||||
queryKey: ["chats"],
|
|
||||||
queryFn: () => client.listChats({ limit: 50 }),
|
|
||||||
enabled: isReady,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(chats);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Button>Test</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
import type { WhatsAppClient } from "@client/whatsapp";
|
import type { WhatsAppClient } from "@client/whatsapp";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import wa from "./wa-client";
|
import wa from "../lib/wa-client";
|
||||||
|
|
||||||
export const WAContext = createContext<{
|
export const WAContext = createContext<{
|
||||||
client: WhatsAppClient;
|
client: WhatsAppClient;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isReady: boolean;
|
isReady: boolean;
|
||||||
|
addListener: (event: string, callback: (...args: any[]) => any) => () => void;
|
||||||
}>(null!);
|
}>(null!);
|
||||||
|
|
||||||
export function useWAClient() {
|
export function useWAClient() {
|
||||||
@@ -15,14 +16,11 @@ export function useWAClient() {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WAProvider({
|
type ListenerMap = Record<string, ((...args: any[]) => any)[]>;
|
||||||
children,
|
|
||||||
url,
|
export function WAProvider({ children }: { children: React.ReactNode }) {
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
url?: string;
|
|
||||||
}) {
|
|
||||||
const isInitialized = useRef(false);
|
const isInitialized = useRef(false);
|
||||||
|
const eventListeners = useRef<ListenerMap>({});
|
||||||
const [isConnected, setConnected] = useState(false);
|
const [isConnected, setConnected] = useState(false);
|
||||||
|
|
||||||
const { data: status } = useQuery({
|
const { data: status } = useQuery({
|
||||||
@@ -31,6 +29,20 @@ export function WAProvider({
|
|||||||
enabled: isConnected,
|
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(() => {
|
useEffect(() => {
|
||||||
if (isInitialized.current) return;
|
if (isInitialized.current) return;
|
||||||
|
|
||||||
@@ -38,9 +50,8 @@ export function WAProvider({
|
|||||||
isInitialized.current = true;
|
isInitialized.current = true;
|
||||||
|
|
||||||
wa.on("status", setConnected);
|
wa.on("status", setConnected);
|
||||||
}, [url]);
|
wa.on("message", (data) => emit("message", data));
|
||||||
|
}, []);
|
||||||
console.log("status", status);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WAContext.Provider
|
<WAContext.Provider
|
||||||
@@ -48,6 +59,7 @@ export function WAProvider({
|
|||||||
client: wa,
|
client: wa,
|
||||||
isConnected,
|
isConnected,
|
||||||
isReady: status?.authenticated && status?.ready ? true : false,
|
isReady: status?.authenticated && status?.ready ? true : false,
|
||||||
|
addListener,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+721
-162
@@ -1,125 +1,309 @@
|
|||||||
import { useWAClient } from "@/lib/context";
|
import { useWAClient } from "./context";
|
||||||
import { getFormattedName, getId, getInitial } from "@/lib/utils";
|
import {
|
||||||
import type { ChatDTO } from "@shared/dto";
|
copyToClipboard,
|
||||||
import { useQuery } from "@tanstack/react-query";
|
getFilesAsBase64,
|
||||||
|
getFormattedName,
|
||||||
|
getId,
|
||||||
|
} from "@/lib/utils";
|
||||||
|
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { cn } from "cn";
|
import { cn } from "cn";
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
CircleHelpIcon,
|
CheckCheckIcon,
|
||||||
|
CheckIcon,
|
||||||
|
ChevronDown,
|
||||||
|
CopyIcon,
|
||||||
|
CornerUpRightIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
MousePointer2Icon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
|
PhoneCallIcon,
|
||||||
|
ReplyIcon,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import MessageMedia from "@/components/message-media";
|
||||||
|
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 = {
|
type Props = {
|
||||||
chat: ChatDTO;
|
chatId: string | null;
|
||||||
mobileChat: boolean;
|
maxCount?: number;
|
||||||
setMobileChat: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MessagesSection({
|
type MessageDTOWithStrID = Omit<MessageDTO, "id" | "from"> & {
|
||||||
chat,
|
id: string;
|
||||||
mobileChat,
|
from?: ContactDTO;
|
||||||
setMobileChat,
|
};
|
||||||
}: Props) {
|
|
||||||
|
export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
||||||
const { client, isReady } = useWAClient();
|
const { client, isReady } = useWAClient();
|
||||||
const [message, setMessage] = useState("");
|
const router = useHashRouter();
|
||||||
|
const msgContainerRef = useRef<HTMLDivElement>(null!);
|
||||||
|
const lastFetch = useRef<number>(0);
|
||||||
|
|
||||||
const { data: contacts } = useQuery({
|
const queryClient = useQueryClient();
|
||||||
queryKey: ["contacts"],
|
const { data: chats } = useChats();
|
||||||
queryFn: () => client.listContacts(),
|
const { data: contacts } = useContacts();
|
||||||
refetchOnWindowFocus: false,
|
const [search, setSearch] = useState("");
|
||||||
});
|
const [selectedIds, setSelectedIds] = useState<Set<string> | null>(null);
|
||||||
|
const [replyTo, setReplyTo] = useState<MessageDTOWithStrID | null>(null);
|
||||||
|
|
||||||
const chatId = getId(chat.id);
|
const {
|
||||||
const { data: messages } = useQuery({
|
data: messages,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
queryKey: ["messages", chatId],
|
queryKey: ["messages", chatId],
|
||||||
queryFn: () =>
|
queryFn: ({ pageParam }) =>
|
||||||
client.getMessages({ chatId, count: 50 }).then((res) =>
|
client
|
||||||
res.map((i) => ({
|
.getMessages({
|
||||||
...i,
|
chatId: chatId!,
|
||||||
id: getId(i.id),
|
count: maxCount,
|
||||||
fromMe: i.id.fromMe || i.fromMe,
|
direction: "before",
|
||||||
})),
|
id: pageParam,
|
||||||
),
|
})
|
||||||
|
.then((i) => i.reverse()),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
enabled: Boolean(chatId && isReady),
|
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 messageList = useMemo(() => {
|
||||||
return messages
|
const q = search.trim().toLowerCase();
|
||||||
?.filter((i) => !["call_log", "revoked"].includes(i.type as never))
|
|
||||||
.map((message) => {
|
|
||||||
const from = contacts?.find(
|
|
||||||
(c) => getId(c.id) === getId(message.author || message.from),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
from,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
?.reverse();
|
|
||||||
}, [messages, contacts]);
|
|
||||||
|
|
||||||
function sendMessage() {
|
return messages
|
||||||
const text = message.trim();
|
?.filter((i) => {
|
||||||
if (!text) return;
|
if (["revoked"].includes(i.type as never)) {
|
||||||
setMessage("");
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`flex min-w-0 flex-1 flex-col bg-muted ${mobileChat ? "flex" : "hidden md:flex"}`}
|
className={`flex min-w-0 h-full flex-1 flex-col bg-muted ${chatId ? "flex" : "hidden md:flex"}`}
|
||||||
>
|
>
|
||||||
<header className="flex items-center justify-between border-b border-border bg-card px-4 py-3 sm:px-6">
|
<header className="flex items-center justify-between border-b border-border bg-card px-4 py-3 sm:px-6">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
{selectedIds?.size ? (
|
||||||
<button
|
<div className="flex items-center gap-3 w-full">
|
||||||
aria-label="Back to conversations"
|
<button
|
||||||
onClick={() => setMobileChat(false)}
|
aria-label="Cancel selection"
|
||||||
className="grid size-9 place-items-center rounded-full text-muted-foreground hover:bg-muted md:hidden"
|
onClick={() => setSelectedIds(null)}
|
||||||
>
|
className="grid size-10 place-items-center rounded-full text-muted-foreground hover:bg-muted cursor-pointer -ml-2"
|
||||||
<ArrowLeftIcon size={19} />
|
>
|
||||||
</button>
|
<XIcon size={19} />
|
||||||
|
</button>
|
||||||
|
<p className="text-sm font-medium truncate">{`${selectedIds.size} selected`}</p>
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
<div
|
<Button
|
||||||
className={cn(
|
variant="ghost"
|
||||||
"grid size-10 shrink-0 place-items-center rounded-full text-sm font-semibold",
|
size="icon-lg"
|
||||||
)}
|
className="rounded-full"
|
||||||
>
|
title="Forward"
|
||||||
{getInitial(chat.formattedTitle)}
|
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>
|
||||||
<div className="min-w-0">
|
) : (
|
||||||
<h2 className="truncate text-sm font-semibold">
|
<div className="flex min-w-0 items-center gap-3 w-full">
|
||||||
{chat.formattedTitle || chat.name}
|
<button
|
||||||
</h2>
|
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>
|
||||||
|
|
||||||
{/* <p className="text-xs text-[#1ea66a]">
|
<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"}
|
{selected.online ? "Online now" : "Active recently"}
|
||||||
</p> */}
|
</p> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchInput
|
||||||
|
className="w-full max-w-40 md:max-w-sm"
|
||||||
|
inputClassName="text-xs placeholder:text-xs"
|
||||||
|
placeholder="Search..."
|
||||||
|
defaultValue={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{/* <div className="flex items-center gap-1 text-muted-foreground">
|
|
||||||
<button
|
|
||||||
aria-label="Start video call"
|
|
||||||
className="grid size-9 place-items-center rounded-full hover:bg-muted"
|
|
||||||
>
|
|
||||||
<VideoIcon size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-label="Start voice call"
|
|
||||||
className="grid size-9 place-items-center rounded-full hover:bg-muted"
|
|
||||||
>
|
|
||||||
<Phone size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-label="Chat details"
|
|
||||||
className="grid size-9 place-items-center rounded-full hover:bg-muted"
|
|
||||||
>
|
|
||||||
<EllipsisVertical size={18} />
|
|
||||||
</button>
|
|
||||||
</div> */}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="message-pattern flex-1 px-4 py-6 sm:px-8 flex flex-col-reverse overflow-y-auto">
|
<div
|
||||||
|
ref={msgContainerRef}
|
||||||
|
className="message-pattern flex-1 py-6 flex flex-col-reverse overflow-y-auto"
|
||||||
|
>
|
||||||
{/* <div className="mb-6 flex items-center justify-center gap-2 text-[11px] text-muted-foreground">
|
{/* <div className="mb-6 flex items-center justify-center gap-2 text-[11px] text-muted-foreground">
|
||||||
<span className="h-px flex-1 bg-border" />
|
<span className="h-px flex-1 bg-border" />
|
||||||
<span>Today</span>
|
<span>Today</span>
|
||||||
@@ -128,114 +312,489 @@ export default function MessagesSection({
|
|||||||
|
|
||||||
{messageList?.map((item, index) => (
|
{messageList?.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
|
id={item.id.replace(/^(?:false_|true_)/, "")}
|
||||||
key={`${item.timestamp}-${index}`}
|
key={`${item.timestamp}-${index}`}
|
||||||
className={`mb-3 flex ${item.fromMe ? "justify-end" : "justify-start"}`}
|
className={cn(
|
||||||
|
"flex px-4 sm:px-8 py-1.5",
|
||||||
|
item.fromMe ? "justify-end" : "justify-start",
|
||||||
|
selectedIds != null && "cursor-pointer hover:bg-[#26855c]/10",
|
||||||
|
selectedIds?.has(item.id) && "bg-[#26855c]/30!",
|
||||||
|
)}
|
||||||
|
role={selectedIds != null ? "button" : undefined}
|
||||||
|
onClick={
|
||||||
|
selectedIds != null
|
||||||
|
? (e) => {
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
onSelectMsg(item.id);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="max-w-[82%] sm:max-w-[65%]">
|
<div className="max-w-[82%] sm:max-w-[65%]">
|
||||||
<p className="mb-1 text-xs text-muted-foreground">
|
<a
|
||||||
{getFormattedName(item.from) || item.notifyName}
|
href="#"
|
||||||
</p>
|
onClick={(e) => {
|
||||||
<div
|
e.preventDefault();
|
||||||
className={`rounded-2xl px-4 py-2.5 text-sm shadow-sm ${item.fromMe ? "rounded-br-md bg-[#d8f5e8] dark:bg-[#26855c] text-foreground" : "rounded-bl-md bg-card text-card-foreground"}`}
|
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 ? (
|
{item.isForwarded ? (
|
||||||
<p className="text-xs italic mb-1 text-primary/80">
|
<p className="text-xs italic mb-1 text-primary/80">
|
||||||
Forwarded
|
Forwarded
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{item.quotedMsg && item.quotedMsg.type === "chat" ? (
|
{/* <pre className="overflow-y-auto max-h-80">
|
||||||
<div
|
{JSON.stringify(item, null, 2)}
|
||||||
className={cn(
|
</pre> */}
|
||||||
"bg-card/30 px-2 py-1.5 rounded-lg mb-2 mt-1",
|
|
||||||
!item.fromMe && "bg-[#d8f5e8]/30 dark:bg-[#26855c]/30",
|
{item.quotedMsg &&
|
||||||
)}
|
["chat", "image", "document"].includes(
|
||||||
>
|
item.quotedMsg.type as string,
|
||||||
<p className="whitespace-pre-wrap">{item.quotedMsg.body}</p>
|
) ? (
|
||||||
</div>
|
<QuotedMsg
|
||||||
|
data={item.quotedMsg as never}
|
||||||
|
getMentionLabel={getMentionLabel}
|
||||||
|
onMentionClick={onMentionClick}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{item.type === "image" && (
|
{item.type === "image" ||
|
||||||
<img
|
item.type === "sticker" ||
|
||||||
src={"data:image/jpeg;base64," + item.body}
|
item.type === "document" ||
|
||||||
alt=""
|
item.type === "ptt" ? (
|
||||||
className="w-full"
|
<MessageMedia data={item as never} />
|
||||||
/>
|
) : null}
|
||||||
)}
|
|
||||||
|
|
||||||
{item.type === "chat" || item.caption ? (
|
{item.type === "chat" || item.caption ? (
|
||||||
<p className="whitespace-pre-wrap">
|
<Paragraph
|
||||||
{item.caption || item.body}
|
text={item.caption || item.body}
|
||||||
</p>
|
getMentionLabel={getMentionLabel}
|
||||||
|
onMentionClick={onMentionClick}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{item.type === "sticker" && <p className="italic">Sticker</p>}
|
{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
|
<div
|
||||||
className={`mt-1 flex items-center justify-end gap-1 text-[10px] ${item.fromMe ? "text-primary" : "text-muted-foreground"}`}
|
className={`mt-1 flex items-center justify-end gap-1 text-[10px] ${item.fromMe ? "text-primary" : "text-muted-foreground"}`}
|
||||||
>
|
>
|
||||||
<span>{item.timestamp}</span>
|
<span>{dayjs((item.timestamp ?? 0) * 1000).fromNow()}</span>
|
||||||
{/* {item.fromMe &&
|
|
||||||
(item.read ? (
|
{item.fromMe && (
|
||||||
<CheckCheck size={14} />
|
<>
|
||||||
) : (
|
{item.ack && item.ack >= 2 ? (
|
||||||
<Check size={13} />
|
<CheckCheckIcon
|
||||||
))} */}
|
size={14}
|
||||||
|
className={item.ack === 3 ? "text-blue-300" : ""}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CheckIcon size={13} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border bg-card px-3 py-3 sm:px-6 sm:py-4">
|
<MessageInputSection
|
||||||
<div className="mx-auto flex max-w-2xl items-end gap-2">
|
chatId={chatId}
|
||||||
<button
|
refetch={refetch}
|
||||||
aria-label="Add attachment"
|
replyTo={replyTo}
|
||||||
className="grid size-10 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-muted-foreground"
|
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)}
|
||||||
>
|
>
|
||||||
<PaperclipIcon size={19} />
|
<XIcon />
|
||||||
</button>
|
</Button>
|
||||||
<div className="flex min-h-10 flex-1 items-center gap-2 rounded-2xl bg-muted px-3">
|
</div>
|
||||||
{/* <button
|
)}
|
||||||
|
|
||||||
|
<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"
|
aria-label="Add emoji"
|
||||||
className="shrink-0 text-muted-foreground hover:text-muted-foreground"
|
className="shrink-0 text-muted-foreground hover:text-muted-foreground"
|
||||||
>
|
>
|
||||||
<SmileIcon size={19} />
|
<SmileIcon size={19} />
|
||||||
</button> */}
|
</button> */}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(event) => setMessage(event.target.value)}
|
onChange={(event) => setMessage(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onPaste={onPaste}
|
||||||
if (
|
enterKeyHint="enter"
|
||||||
event.key === "Enter" &&
|
onKeyDown={(event) => {
|
||||||
!event.shiftKey &&
|
if (
|
||||||
!event.nativeEvent.isComposing &&
|
event.key === "Enter" &&
|
||||||
event.keyCode !== 229
|
!event.shiftKey &&
|
||||||
) {
|
!event.nativeEvent.isComposing &&
|
||||||
event.preventDefault();
|
event.keyCode !== 229
|
||||||
sendMessage();
|
) {
|
||||||
}
|
event.preventDefault();
|
||||||
}}
|
sendMessage();
|
||||||
placeholder="Write a message..."
|
}
|
||||||
rows={1}
|
}}
|
||||||
className="max-h-24 min-h-10 flex-1 resize-none bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground"
|
placeholder="Write a message..."
|
||||||
/>
|
rows={1}
|
||||||
</div>
|
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"
|
||||||
<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={!message.trim()}
|
|
||||||
>
|
|
||||||
<SendIcon size={17} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mx-auto mt-2 hidden max-w-2xl items-center justify-center gap-1 text-[10px] text-muted-foreground sm:flex">
|
|
||||||
<CircleHelpIcon size={12} /> Press Enter to send
|
|
||||||
</div>
|
</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>
|
||||||
</section>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-228
@@ -1,147 +1,31 @@
|
|||||||
import { useMemo, useState } from "react";
|
import ChatsSection from "./chats";
|
||||||
import {
|
import DevicesSection from "./devices";
|
||||||
ChevronDown,
|
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||||
Edit3,
|
import { useHashRouter } from "@/hooks/useHashRouter";
|
||||||
EllipsisVertical,
|
|
||||||
Search,
|
|
||||||
Sparkles,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useWAClient } from "@/lib/context";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { cn, getId, getInitial } from "@/lib/utils";
|
|
||||||
import MessagesSection from "./messages";
|
import MessagesSection from "./messages";
|
||||||
|
import ForwardDialog from "@/components/forward-dialog";
|
||||||
// type Chat = {
|
import { useEffect } from "react";
|
||||||
// id: number;
|
import wa from "@/lib/wa-client";
|
||||||
// name: string;
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
// initials: string;
|
|
||||||
// color: string;
|
|
||||||
// preview: string;
|
|
||||||
// time: string;
|
|
||||||
// unread?: number;
|
|
||||||
// online?: boolean;
|
|
||||||
// messages: { text: string; time: string; fromMe?: boolean; read?: boolean }[];
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const chats: Chat[] = [
|
|
||||||
// {
|
|
||||||
// id: 1,
|
|
||||||
// name: "Maya Chen",
|
|
||||||
// initials: "MC",
|
|
||||||
// color: "bg-rose-200 text-rose-800",
|
|
||||||
// preview: "That sounds perfect. See you then!",
|
|
||||||
// time: "10:42 AM",
|
|
||||||
// online: true,
|
|
||||||
// messages: [
|
|
||||||
// { text: "Hey! Are we still on for coffee tomorrow?", time: "10:38 AM" },
|
|
||||||
// {
|
|
||||||
// text: "Absolutely. I found a new spot near the park.",
|
|
||||||
// time: "10:40 AM",
|
|
||||||
// fromMe: true,
|
|
||||||
// read: true,
|
|
||||||
// },
|
|
||||||
// { text: "That sounds perfect. See you then!", time: "10:42 AM" },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 2,
|
|
||||||
// name: "Design team",
|
|
||||||
// initials: "DT",
|
|
||||||
// color: "bg-violet-200 text-violet-800",
|
|
||||||
// preview: "Jon: The new explorations are ready",
|
|
||||||
// time: "9:16 AM",
|
|
||||||
// unread: 3,
|
|
||||||
// messages: [
|
|
||||||
// { text: "Jon: The new explorations are ready", time: "9:16 AM" },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 3,
|
|
||||||
// name: "Alex Morgan",
|
|
||||||
// initials: "AM",
|
|
||||||
// color: "bg-amber-200 text-amber-800",
|
|
||||||
// preview: "You: Thanks for sending that over",
|
|
||||||
// time: "Yesterday",
|
|
||||||
// messages: [
|
|
||||||
// {
|
|
||||||
// text: "Thanks for sending that over",
|
|
||||||
// time: "Yesterday",
|
|
||||||
// fromMe: true,
|
|
||||||
// read: true,
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 4,
|
|
||||||
// name: "Weekend plans",
|
|
||||||
// initials: "WP",
|
|
||||||
// color: "bg-emerald-200 text-emerald-800",
|
|
||||||
// preview: "Priya: I can bring the snacks",
|
|
||||||
// time: "Yesterday",
|
|
||||||
// unread: 8,
|
|
||||||
// messages: [{ text: "Priya: I can bring the snacks", time: "Yesterday" }],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 5,
|
|
||||||
// name: "Noah Williams",
|
|
||||||
// initials: "NW",
|
|
||||||
// color: "bg-sky-200 text-sky-800",
|
|
||||||
// preview: "Voice message",
|
|
||||||
// time: "Tuesday",
|
|
||||||
// messages: [{ text: "Voice message", time: "Tuesday" }],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 6,
|
|
||||||
// name: "Sofia Patel",
|
|
||||||
// initials: "SP",
|
|
||||||
// color: "bg-orange-200 text-orange-800",
|
|
||||||
// preview: "See you at the studio!",
|
|
||||||
// time: "Monday",
|
|
||||||
// messages: [{ text: "See you at the studio!", time: "Monday" }],
|
|
||||||
// },
|
|
||||||
// ];
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const [selectedId, setSelectedId] = useState<string>();
|
const router = useHashRouter();
|
||||||
const [query, setQuery] = useState("");
|
const queryClient = useQueryClient();
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [curDeviceId, setDeviceId] = useLocalStorage<string>("app/device", "");
|
||||||
const [mobileChat, setMobileChat] = useState(false);
|
const chatId = router.query.get("chatId");
|
||||||
|
|
||||||
const { client, isReady } = useWAClient();
|
useEffect(() => {
|
||||||
const { data: chats } = useQuery({
|
wa.setDeviceId(curDeviceId);
|
||||||
queryKey: ["chats"],
|
queryClient.invalidateQueries();
|
||||||
queryFn: () =>
|
}, [curDeviceId]);
|
||||||
client
|
|
||||||
.listChats({ limit: 500 })
|
|
||||||
.then((res) => res.map((i) => ({ ...i, id: getId(i.id) }))),
|
|
||||||
enabled: isReady,
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredChats = useMemo(
|
|
||||||
() =>
|
|
||||||
chats?.filter(
|
|
||||||
(chat) =>
|
|
||||||
chat.formattedTitle?.toLowerCase().includes(query.toLowerCase()) ||
|
|
||||||
chat.lastMessage?.body?.toLowerCase().includes(query.toLowerCase()),
|
|
||||||
),
|
|
||||||
[chats, query],
|
|
||||||
);
|
|
||||||
|
|
||||||
function openChat(id: string) {
|
|
||||||
setSelectedId(id);
|
|
||||||
setMobileChat(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedChat = chats?.find((chat) => chat.id === selectedId);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-dvh bg-background p-0 text-foreground sm:p-4 lg:p-6">
|
<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)]">
|
{/* <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
|
<aside
|
||||||
className={`flex w-full shrink-0 flex-col border-r border-border bg-card md:w-85 lg:w-95 ${mobileChat ? "hidden md:flex" : "flex"}`}
|
className={`flex w-full h-full shrink-0 flex-col border-r border-border bg-card md:w-85 lg:w-1/3 max-w-md ${chatId ? "hidden md:flex" : "flex"}`}
|
||||||
>
|
>
|
||||||
<header className="flex items-center justify-between border-b border-border px-5 py-5">
|
{/* <header className="flex items-center justify-between border-b border-border px-5 py-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="grid size-10 place-items-center rounded-xl bg-accent text-accent-foreground">
|
<div className="grid size-10 place-items-center rounded-xl bg-accent text-accent-foreground">
|
||||||
<Sparkles aria-hidden="true" />
|
<Sparkles aria-hidden="true" />
|
||||||
@@ -167,86 +51,16 @@ export default function Page() {
|
|||||||
<EllipsisVertical size={19} />
|
<EllipsisVertical size={19} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header> */}
|
||||||
<div className="px-4 py-3">
|
|
||||||
{showSearch ? (
|
<ChatsSection
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-muted px-3 py-2">
|
selected={chatId}
|
||||||
<Search size={17} className="text-muted-foreground" />
|
onSelect={(id) =>
|
||||||
<input
|
router.navigate(`/?chatId=${id}`, { replace: !!chatId })
|
||||||
autoFocus
|
}
|
||||||
value={query}
|
/>
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder="Search conversations"
|
{/* <div className="border-t border-border px-5 py-4">
|
||||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
aria-label="Close search"
|
|
||||||
onClick={() => {
|
|
||||||
setShowSearch(false);
|
|
||||||
setQuery("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={16} className="text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSearch(true)}
|
|
||||||
className="flex w-full items-center gap-3 rounded-xl bg-muted px-3 py-2.5 text-left text-sm text-muted-foreground transition hover:bg-border"
|
|
||||||
>
|
|
||||||
<Search size={17} /> Search conversations
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
{filteredChats?.map((chat) => (
|
|
||||||
<button
|
|
||||||
key={chat.id}
|
|
||||||
onClick={() => openChat(chat.id)}
|
|
||||||
className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition ${selectedId === chat.id ? "bg-accent" : "hover:bg-muted/70"}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative grid size-11 shrink-0 place-items-center rounded-full text-sm font-semibold bg-rose-200 text-rose-800",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{getInitial(chat.formattedTitle)}
|
|
||||||
{/* {chat.initials}
|
|
||||||
{chat.online && (
|
|
||||||
<span className="absolute bottom-0 right-0 size-3 rounded-full border-2 border-card bg-primary" />
|
|
||||||
)} */}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="truncate text-sm font-semibold">
|
|
||||||
{chat.formattedTitle || chat.name}
|
|
||||||
</p>
|
|
||||||
{/* <span className="shrink-0 text-[11px] text-muted-foreground">
|
|
||||||
{chat.timestamp}
|
|
||||||
</span> */}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center justify-between gap-2">
|
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
|
||||||
{chat.lastMessage?.body?.substring(0, 50)}
|
|
||||||
</p>
|
|
||||||
{/* {chat.unread && (
|
|
||||||
<span className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
|
||||||
{chat.unread}
|
|
||||||
</span>
|
|
||||||
)} */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-border px-5 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<div className="grid size-9 place-items-center rounded-full bg-border text-xs font-semibold text-muted-foreground">
|
||||||
JD
|
JD
|
||||||
@@ -264,17 +78,15 @@ export default function Page() {
|
|||||||
<ChevronDown size={17} />
|
<ChevronDown size={17} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
</aside>
|
|
||||||
|
|
||||||
{selectedChat ? (
|
<DevicesSection curDeviceId={curDeviceId} setDeviceId={setDeviceId} />
|
||||||
<MessagesSection
|
</aside>
|
||||||
chat={selectedChat}
|
|
||||||
mobileChat={mobileChat}
|
{chatId ? <MessagesSection chatId={chatId} /> : null}
|
||||||
setMobileChat={setMobileChat}
|
{/* </div> */}
|
||||||
/>
|
|
||||||
) : null}
|
<ForwardDialog />
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createDisclosure } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const forwardDlg = createDisclosure<{
|
||||||
|
messageIds: string[];
|
||||||
|
}>();
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -144,3 +144,27 @@
|
|||||||
font-size: 16px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
+42
-2
@@ -1,5 +1,5 @@
|
|||||||
import type { ContactDTO, MessageIdDTO, SerializedId } from "@shared/dto";
|
import type { ContactDTO, MessageIdDTO, SerializedId } from "@shared/dto";
|
||||||
|
import { createStore, useStore } from "zustand";
|
||||||
export { cn } from "cn";
|
export { cn } from "cn";
|
||||||
|
|
||||||
export function getId(id?: string | SerializedId | MessageIdDTO | null) {
|
export function getId(id?: string | SerializedId | MessageIdDTO | null) {
|
||||||
@@ -10,7 +10,11 @@ export function getId(id?: string | SerializedId | MessageIdDTO | null) {
|
|||||||
export function getInitial(name?: string | null) {
|
export function getInitial(name?: string | null) {
|
||||||
return (
|
return (
|
||||||
name
|
name
|
||||||
?.split(" ")
|
?.replace("dr.", "")
|
||||||
|
.replace(/[^a-zA-Z ]/g, "")
|
||||||
|
.toUpperCase()
|
||||||
|
.trim()
|
||||||
|
.split(" ")
|
||||||
.map((i) => i[0])
|
.map((i) => i[0])
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.join("") || ""
|
.join("") || ""
|
||||||
@@ -29,3 +33,39 @@ export function getFormattedName(
|
|||||||
? name.user || ""
|
? 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,16 +1,16 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { WAProvider } from "./lib/context.tsx";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { WAProvider } from "./app/context.tsx";
|
||||||
import App from "./app/page.tsx";
|
import App from "./app/page.tsx";
|
||||||
import "./app/globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const queryCLient = new QueryClient();
|
const queryCLient = new QueryClient();
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryCLient}>
|
<QueryClientProvider client={queryCLient}>
|
||||||
<WAProvider url={import.meta.env.VITE_WHATSAPP_URL}>
|
<WAProvider>
|
||||||
<App />
|
<App />
|
||||||
</WAProvider>
|
</WAProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import tailwindcss from "@tailwindcss/vite";
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
allowedHosts: true,
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(import.meta.dirname, "./src"),
|
"@": path.resolve(import.meta.dirname, "./src"),
|
||||||
|
|||||||
Reference in New Issue
Block a user