Compare commits
8
Commits
c9c55f56c8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
166c96caf2 | ||
|
|
b7bbce7374 | ||
|
|
21c30393fc | ||
|
|
999ecc1a61 | ||
|
|
495ee9c040 | ||
|
|
6513dc39b6 | ||
|
|
0ece6bbb63 | ||
|
|
07dfb7d443 |
+16
-4
@@ -16,6 +16,7 @@ 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,
|
||||||
@@ -28,8 +29,13 @@ export class WhatsAppClient {
|
|||||||
|
|
||||||
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() {
|
||||||
@@ -83,12 +89,17 @@ export class WhatsAppClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private request<T>(method: string, params?: unknown): Promise<T> {
|
private request<T>(
|
||||||
|
method: string,
|
||||||
|
params?: unknown,
|
||||||
|
devId?: string | null,
|
||||||
|
): Promise<T> {
|
||||||
if (!this.isConnected || !this.socket) {
|
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(() => {
|
||||||
@@ -106,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,
|
||||||
@@ -167,7 +179,7 @@ export class WhatsAppClient {
|
|||||||
return this.request<string>("getMedia", msgId);
|
return this.request<string>("getMedia", msgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
exec(cmd: string) {
|
exec(cmd: string, deviceId?: string | null) {
|
||||||
return this.request<any>("exec", cmd);
|
return this.request<any>("exec", cmd, deviceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-14
@@ -28,15 +28,37 @@ function requestId() {
|
|||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDevices() {
|
||||||
|
return [
|
||||||
|
...new Map(
|
||||||
|
[...bridgeSockets].reverse().map((ws) => [
|
||||||
|
ws.deviceId,
|
||||||
|
{
|
||||||
|
id: ws.deviceId,
|
||||||
|
name: ws.deviceName,
|
||||||
|
lastAlive: ws.lastAlive,
|
||||||
|
lastSuccessfulCall: ws.lastSuccessfulCall,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function getWhatsApp(deviceId?: string): WebSocket | null {
|
function getWhatsApp(deviceId?: string): WebSocket | null {
|
||||||
for (const ws of [...bridgeSockets].reverse()) {
|
if (deviceId) {
|
||||||
if (
|
for (const ws of [...bridgeSockets].reverse()) {
|
||||||
ws.readyState === WebSocket.OPEN &&
|
if (ws.readyState === WebSocket.OPEN && ws.deviceId === deviceId) {
|
||||||
(!deviceId || ws.deviceId === deviceId)
|
return ws;
|
||||||
) {
|
}
|
||||||
return ws;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// find the most recent active bridge
|
||||||
|
const sock = [...bridgeSockets]
|
||||||
|
.sort((a, b) => (b.lastSuccessfulCall || 0) - (a.lastSuccessfulCall || 0))
|
||||||
|
.find((i) => i.readyState === WebSocket.OPEN && i.isAuthenticated);
|
||||||
|
if (sock) return sock;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +97,18 @@ function call<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const forwardedMessages = new Map<string, number>();
|
||||||
|
|
||||||
|
function cleanupForwardedMessages() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [id, timestamp] of forwardedMessages) {
|
||||||
|
if (now - timestamp > 60_000) {
|
||||||
|
forwardedMessages.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleWhatsAppMessage(ws: WebSocket, data: string) {
|
function handleWhatsAppMessage(ws: WebSocket, data: string) {
|
||||||
const message = JSON.parse(data) as
|
const message = JSON.parse(data) as
|
||||||
| RpcResponse
|
| RpcResponse
|
||||||
@@ -119,21 +153,29 @@ function handleWhatsAppMessage(ws: WebSocket, data: string) {
|
|||||||
console.log("[WhatsApp event]", event, data);
|
console.log("[WhatsApp event]", event, data);
|
||||||
|
|
||||||
if (event === "ready") {
|
if (event === "ready") {
|
||||||
|
ws.isAuthenticated = data.authenticated || false;
|
||||||
ws.deviceId = data.deviceId;
|
ws.deviceId = data.deviceId;
|
||||||
ws.deviceName = data.deviceName;
|
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 || "",
|
||||||
@@ -204,11 +246,7 @@ wss.on("connection", (ws) => {
|
|||||||
|
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case "listDevices":
|
case "listDevices":
|
||||||
result = [...bridgeSockets].map((ws) => ({
|
result = getDevices();
|
||||||
id: ws.deviceId,
|
|
||||||
name: ws.deviceName,
|
|
||||||
lastAlive: ws.lastAlive,
|
|
||||||
}));
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// cached
|
// cached
|
||||||
@@ -240,6 +278,8 @@ wss.on("connection", (ws) => {
|
|||||||
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",
|
||||||
@@ -276,4 +316,5 @@ wss.on("connection", (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
+3
-1
@@ -2,8 +2,10 @@ import "ws";
|
|||||||
|
|
||||||
declare module "ws" {
|
declare module "ws" {
|
||||||
interface WebSocket {
|
interface WebSocket {
|
||||||
lastAlive?: number;
|
isAuthenticated?: boolean;
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
|
lastAlive?: number;
|
||||||
|
lastSuccessfulCall?: number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -5,10 +5,12 @@ import { getId } from "./ui/src/lib/utils";
|
|||||||
const wa = new WhatsAppClient(process.env.WS_URL);
|
const wa = new WhatsAppClient(process.env.WS_URL);
|
||||||
await wa.connect();
|
await wa.connect();
|
||||||
|
|
||||||
// console.log("status", await wa.status());
|
const devices = await wa.listDevices();
|
||||||
|
console.log("devices", devices);
|
||||||
|
|
||||||
// const devices = await wa.listDevices();
|
wa.setDeviceId("1789898358415");
|
||||||
// console.log("devices", devices);
|
// await wa.exec("window.location.reload(); return 1;");
|
||||||
|
console.log("status", await wa.status());
|
||||||
|
|
||||||
// const data = JSON.stringify({
|
// const data = JSON.stringify({
|
||||||
// DEVICE_ID: "1789636162707",
|
// DEVICE_ID: "1789636162707",
|
||||||
@@ -19,7 +21,8 @@ await wa.connect();
|
|||||||
// const test = await wa.exec(`return localStorage.getItem('wa-bridge-consts');`);
|
// const test = await wa.exec(`return localStorage.getItem('wa-bridge-consts');`);
|
||||||
|
|
||||||
// const test = await wa.exec(`return document.hasFocus();`);
|
// const test = await wa.exec(`return document.hasFocus();`);
|
||||||
const test = await wa.exec(`window.location.reload(); return 1;`);
|
// const test = await wa.exec(`window.location.reload(); return 1;`);
|
||||||
|
const test = await wa.listChats();
|
||||||
console.log("test", test);
|
console.log("test", test);
|
||||||
|
|
||||||
// const test = await wa.exec(`return GM_info.script.version;`);
|
// const test = await wa.exec(`return GM_info.script.version;`);
|
||||||
|
|||||||
+105
-4
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { useWAClient } from "./context";
|
import { useWAClient } from "./context";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -8,12 +8,27 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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 = {
|
type Props = {
|
||||||
curDeviceId: string;
|
curDeviceId: string;
|
||||||
setDeviceId: (id: string) => void;
|
setDeviceId: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const commandDlg = createDisclosure<{ deviceId: string }>();
|
||||||
|
|
||||||
export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
||||||
const { client, isReady } = useWAClient();
|
const { client, isReady } = useWAClient();
|
||||||
const { data: devices } = useQuery({
|
const { data: devices } = useQuery({
|
||||||
@@ -35,7 +50,7 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
|||||||
const isOnline = Date.now() - lastAlive <= 60_000;
|
const isOnline = Date.now() - lastAlive <= 60_000;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: i.name || "Unknown",
|
label: i.name || i.id || "Unknown",
|
||||||
value: i.id || String(idx),
|
value: i.id || String(idx),
|
||||||
lastAlive,
|
lastAlive,
|
||||||
isOnline,
|
isOnline,
|
||||||
@@ -48,7 +63,7 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
|||||||
}, [deviceList, curDeviceId]);
|
}, [deviceList, curDeviceId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t p-4">
|
<div className="border-t p-4 flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
items={deviceList}
|
items={deviceList}
|
||||||
value={curDeviceId}
|
value={curDeviceId}
|
||||||
@@ -86,6 +101,92 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => commandDlg.onOpen({ deviceId: curDeviceId })}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<FileTerminalIcon />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<CommandModal />
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+120
-37
@@ -1,5 +1,10 @@
|
|||||||
import { useWAClient } from "./context";
|
import { useWAClient } from "./context";
|
||||||
import { getFilesAsBase64, getFormattedName, getId } from "@/lib/utils";
|
import {
|
||||||
|
copyToClipboard,
|
||||||
|
getFilesAsBase64,
|
||||||
|
getFormattedName,
|
||||||
|
getId,
|
||||||
|
} from "@/lib/utils";
|
||||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { cn } from "cn";
|
import { cn } from "cn";
|
||||||
import {
|
import {
|
||||||
@@ -7,11 +12,13 @@ import {
|
|||||||
CheckCheckIcon,
|
CheckCheckIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
CopyIcon,
|
||||||
CornerUpRightIcon,
|
CornerUpRightIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
MousePointer2Icon,
|
MousePointer2Icon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
PhoneCallIcon,
|
PhoneCallIcon,
|
||||||
|
ReplyIcon,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
XIcon,
|
XIcon,
|
||||||
@@ -39,6 +46,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { forwardDlg } from "./stores";
|
import { forwardDlg } from "./stores";
|
||||||
|
import type { ContactDTO, MessageDTO } from "@shared/dto";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
@@ -47,6 +55,11 @@ type Props = {
|
|||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MessageDTOWithStrID = Omit<MessageDTO, "id" | "from"> & {
|
||||||
|
id: string;
|
||||||
|
from?: ContactDTO;
|
||||||
|
};
|
||||||
|
|
||||||
export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
||||||
const { client, isReady } = useWAClient();
|
const { client, isReady } = useWAClient();
|
||||||
const router = useHashRouter();
|
const router = useHashRouter();
|
||||||
@@ -58,6 +71,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
|||||||
const { data: contacts } = useContacts();
|
const { data: contacts } = useContacts();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string> | null>(null);
|
const [selectedIds, setSelectedIds] = useState<Set<string> | null>(null);
|
||||||
|
const [replyTo, setReplyTo] = useState<MessageDTOWithStrID | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: messages,
|
data: messages,
|
||||||
@@ -153,6 +167,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
|||||||
const quotedMsg = i.quotedMsg
|
const quotedMsg = i.quotedMsg
|
||||||
? {
|
? {
|
||||||
...i.quotedMsg,
|
...i.quotedMsg,
|
||||||
|
fromMe: i.fromMe,
|
||||||
from: contacts?.find((c) => {
|
from: contacts?.find((c) => {
|
||||||
return (
|
return (
|
||||||
getId(c.id) ===
|
getId(c.id) ===
|
||||||
@@ -349,10 +364,31 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
|||||||
<ChevronDown />
|
<ChevronDown />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setReplyTo(item as never)}>
|
||||||
|
<ReplyIcon />
|
||||||
|
Reply
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => onSelectMsg(item.id)}>
|
<DropdownMenuItem onClick={() => onSelectMsg(item.id)}>
|
||||||
<MousePointer2Icon />
|
<MousePointer2Icon />
|
||||||
Select
|
Select
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
copyToClipboard(
|
||||||
|
item.caption ||
|
||||||
|
(item.type === "chat" ? item.body : null) ||
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
Copy
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
forwardDlg.onOpen({ messageIds: [item.id] })
|
forwardDlg.onOpen({ messageIds: [item.id] })
|
||||||
@@ -361,6 +397,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
|||||||
<CornerUpRightIcon />
|
<CornerUpRightIcon />
|
||||||
Forward
|
Forward
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => onDeleteMsg(item.id)}>
|
<DropdownMenuItem onClick={() => onDeleteMsg(item.id)}>
|
||||||
<Trash2Icon />
|
<Trash2Icon />
|
||||||
@@ -383,39 +420,11 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
|||||||
["chat", "image", "document"].includes(
|
["chat", "image", "document"].includes(
|
||||||
item.quotedMsg.type as string,
|
item.quotedMsg.type as string,
|
||||||
) ? (
|
) ? (
|
||||||
<button
|
<QuotedMsg
|
||||||
type="button"
|
data={item.quotedMsg as never}
|
||||||
onClick={() => {
|
getMentionLabel={getMentionLabel}
|
||||||
const id = getId(item.quotedMsgId).replace(
|
onMentionClick={onMentionClick}
|
||||||
/^(?:false_|true_)/,
|
/>
|
||||||
"",
|
|
||||||
);
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"bg-card/30 px-2 py-1.5 rounded-lg mb-2 mt-1 cursor-pointer block text-left",
|
|
||||||
!item.fromMe && "bg-[#d8f5e8]/30 dark:bg-[#26855c]/30",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{getFormattedName(item.quotedMsg.from)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Paragraph
|
|
||||||
text={
|
|
||||||
item.quotedMsg.filename ||
|
|
||||||
item.quotedMsg.caption ||
|
|
||||||
(item.quotedMsg.type === "chat"
|
|
||||||
? item.quotedMsg.body
|
|
||||||
: "Quoted")
|
|
||||||
}
|
|
||||||
getMentionLabel={getMentionLabel}
|
|
||||||
onMentionClick={onMentionClick}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{item.type === "image" ||
|
{item.type === "image" ||
|
||||||
@@ -468,7 +477,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-4 shrink-0 w-full max-w-[44%]",
|
"mb-4 shrink-0 px-4 sm:px-8 w-full max-w-[44%]",
|
||||||
i % 2 === 0 ? "self-start" : "self-end",
|
i % 2 === 0 ? "self-start" : "self-end",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -501,17 +510,72 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MessageInputSection chatId={chatId} refetch={refetch} />
|
<MessageInputSection
|
||||||
|
chatId={chatId}
|
||||||
|
refetch={refetch}
|
||||||
|
replyTo={replyTo}
|
||||||
|
setReplyTo={setReplyTo}
|
||||||
|
/>
|
||||||
</section>
|
</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({
|
function MessageInputSection({
|
||||||
chatId,
|
chatId,
|
||||||
refetch,
|
refetch,
|
||||||
|
replyTo,
|
||||||
|
setReplyTo,
|
||||||
|
getMentionLabel,
|
||||||
}: {
|
}: {
|
||||||
chatId?: string | null;
|
chatId?: string | null;
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
|
replyTo?: MessageDTOWithStrID | null;
|
||||||
|
setReplyTo?: (id: MessageDTOWithStrID | null) => void;
|
||||||
|
getMentionLabel?: (username: string) => string;
|
||||||
}) {
|
}) {
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const { client, isReady } = useWAClient();
|
const { client, isReady } = useWAClient();
|
||||||
@@ -559,7 +623,7 @@ function MessageInputSection({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setPending(true);
|
setPending(true);
|
||||||
const options = { waitForAck: true };
|
const options = { waitForAck: true, quotedMsg: replyTo?.id || undefined };
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
const fileList = await getFilesAsBase64(files.map((f) => f.file));
|
const fileList = await getFilesAsBase64(files.map((f) => f.file));
|
||||||
@@ -583,6 +647,7 @@ function MessageInputSection({
|
|||||||
setTimeout(refetch, 1000);
|
setTimeout(refetch, 1000);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setMessage("");
|
setMessage("");
|
||||||
|
setReplyTo?.(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert((err as Error).message);
|
alert((err as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -596,6 +661,24 @@ function MessageInputSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border bg-card px-3 py-3 sm:py-4">
|
<div className="border-t border-border bg-card px-3 py-3 sm:py-4">
|
||||||
|
{replyTo != null && (
|
||||||
|
<div className="flex items-center gap-1 -mt-2 mb-1">
|
||||||
|
<ReplyIcon className="size-5" />
|
||||||
|
<QuotedMsg
|
||||||
|
data={replyTo}
|
||||||
|
getMentionLabel={getMentionLabel}
|
||||||
|
className="text-sm max-h-32 overflow-hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setReplyTo?.(null)}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
|
|||||||
@@ -4,12 +4,21 @@ import { useLocalStorage } from "@/hooks/useLocalStorage";
|
|||||||
import { useHashRouter } from "@/hooks/useHashRouter";
|
import { useHashRouter } from "@/hooks/useHashRouter";
|
||||||
import MessagesSection from "./messages";
|
import MessagesSection from "./messages";
|
||||||
import ForwardDialog from "@/components/forward-dialog";
|
import ForwardDialog from "@/components/forward-dialog";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import wa from "@/lib/wa-client";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const router = useHashRouter();
|
const router = useHashRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [curDeviceId, setDeviceId] = useLocalStorage<string>("app/device", "");
|
const [curDeviceId, setDeviceId] = useLocalStorage<string>("app/device", "");
|
||||||
const chatId = router.query.get("chatId");
|
const chatId = router.query.get("chatId");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
wa.setDeviceId(curDeviceId);
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
}, [curDeviceId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-background text-foreground flex overflow-hidden h-screen max-h-dvh items-stretch">
|
<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)]"> */}
|
||||||
|
|||||||
@@ -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,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 };
|
||||||
@@ -59,3 +59,13 @@ export function createDisclosure<T = any>(data?: T | null) {
|
|||||||
useStore: () => useStore(store),
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user