Files
whatsapp-bridge/ui/src/app/messages.tsx
T

801 lines
24 KiB
TypeScript

import { useWAClient } from "./context";
import {
copyToClipboard,
getFilesAsBase64,
getFormattedName,
getId,
} from "@/lib/utils";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { cn } from "cn";
import {
ArrowLeftIcon,
CheckCheckIcon,
CheckIcon,
ChevronDown,
CopyIcon,
CornerUpRightIcon,
Loader2Icon,
MousePointer2Icon,
PaperclipIcon,
PhoneCallIcon,
ReplyIcon,
SendIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import MessageMedia from "@/components/message-media";
import { useContacts } from "@/hooks/useContact";
import { useMessageEvent } from "@/hooks/useMessageEvent";
import { useHashRouter } from "@/hooks/useHashRouter";
import { useChats } from "@/hooks/useChats";
import Avatar from "@/components/ui/avatar";
import { useIntersection } from "@/hooks/useIntersection";
import Paragraph from "@/components/paragraph";
import { nanoid } from "nanoid";
import { Skeleton } from "@/components/ui/skeleton";
import SearchInput from "@/components/search-input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { forwardDlg } from "./stores";
import type { ContactDTO, MessageDTO } from "@shared/dto";
dayjs.extend(relativeTime);
type Props = {
chatId: string | null;
maxCount?: number;
};
type MessageDTOWithStrID = Omit<MessageDTO, "id" | "from"> & {
id: string;
from?: ContactDTO;
};
export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
const { client, isReady } = useWAClient();
const router = useHashRouter();
const msgContainerRef = useRef<HTMLDivElement>(null!);
const lastFetch = useRef<number>(0);
const queryClient = useQueryClient();
const { data: chats } = useChats();
const { data: contacts } = useContacts();
const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string> | null>(null);
const [replyTo, setReplyTo] = useState<MessageDTOWithStrID | null>(null);
const {
data: messages,
isLoading,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["messages", chatId],
queryFn: ({ pageParam }) =>
client
.getMessages({
chatId: chatId!,
count: maxCount,
direction: "before",
id: pageParam,
})
.then((i) => i.reverse()),
initialPageParam: undefined as string | undefined,
enabled: Boolean(chatId && isReady),
getNextPageParam: (lastPage) => {
if (lastPage.length < maxCount) return undefined;
return getId(lastPage[lastPage.length - 1]?.id);
},
select: (data) => data.pages.flat(),
});
useEffect(() => {
if (chatId) {
msgContainerRef.current.scrollTop = 0;
setSelectedIds(null);
}
}, [chatId]);
useMessageEvent((data) => {
if (data.chatId !== chatId) return;
queryClient.setQueryData(["messages", chatId], (old: any) => {
if (!old) return old;
return {
...old,
pages: [[data, ...old.pages[0]], ...old.pages.slice(1)],
};
});
});
const endScrollRef = useIntersection(() => {
if (!hasNextPage || isFetchingNextPage || isLoading) return;
const t = Date.now() - lastFetch.current;
if (t < 1000) return;
fetchNextPage().then(() => {
lastFetch.current = Date.now();
msgContainerRef.current.scrollTop -= 10;
});
});
const chat = useMemo(
() => chats?.find((c) => c.id === chatId),
[chats, chatId],
);
const messageList = useMemo(() => {
const q = search.trim().toLowerCase();
return messages
?.filter((i) => {
if (["revoked"].includes(i.type as never)) {
return false;
}
if (q) {
const body = i.caption || (i.type === "chat" ? i.body : "") || "";
const name = getFormattedName(i.from) || "";
return (
body.toLowerCase().includes(q) ||
name.toLowerCase().includes(q) ||
i.filename?.toLowerCase().includes(q)
);
}
return true;
})
?.map((i) => {
const from = contacts?.find(
(c) => getId(c.id) === getId(i.author || i.from),
);
const quotedMsg = i.quotedMsg
? {
...i.quotedMsg,
fromMe: i.fromMe,
from: contacts?.find((c) => {
return (
getId(c.id) ===
getId(i.quotedMsg?.author || i.quotedMsg?.from)
);
}),
}
: null;
return {
...i,
id: getId(i.id),
from,
quotedMsg,
};
});
}, [messages, contacts, search]);
function onSelectMsg(id: string) {
setSelectedIds((s) => {
const set = new Set(s || []);
if (!set.has(id)) {
set.add(id);
} else {
set.delete(id);
}
return !set.size ? null : set;
});
}
async function onDeleteMsg(id: string | string[]) {
if (!window.confirm("Delete this message?")) return;
try {
await client.exec(
`return WPP.chat.deleteMessage('${chatId}', ${JSON.stringify(id)}, true, true);`,
);
refetch();
} catch (err) {
console.error(err);
}
}
function getMentionLabel(username: string) {
const data = contacts?.find((c) => (c.id as any).user === username);
return data?.notifyName || data?.formattedName || data?.name || username;
}
function onMentionClick(username: string) {
const data = contacts?.find((c) => (c.id as any).user === username);
if (data) {
router.push(`/?chatId=${getId(data.id)}`);
}
}
return (
<section
className={`flex min-w-0 h-full flex-1 flex-col bg-muted ${chatId ? "flex" : "hidden md:flex"}`}
>
<header className="flex items-center justify-between border-b border-border bg-card px-4 py-3 sm:px-6">
{selectedIds?.size ? (
<div className="flex items-center gap-3 w-full">
<button
aria-label="Cancel selection"
onClick={() => setSelectedIds(null)}
className="grid size-10 place-items-center rounded-full text-muted-foreground hover:bg-muted cursor-pointer -ml-2"
>
<XIcon size={19} />
</button>
<p className="text-sm font-medium truncate">{`${selectedIds.size} selected`}</p>
<div className="flex-1" />
<Button
variant="ghost"
size="icon-lg"
className="rounded-full"
title="Forward"
onClick={() => {
forwardDlg.onOpen({ messageIds: [...selectedIds] });
setSelectedIds(null);
}}
>
<CornerUpRightIcon />
</Button>
<Button
variant="ghost"
size="icon-lg"
className="rounded-full"
title="Delete"
onClick={() => {
onDeleteMsg([...selectedIds]);
setSelectedIds(null);
}}
>
<Trash2Icon />
</Button>
</div>
) : (
<div className="flex min-w-0 items-center gap-3 w-full">
<button
aria-label="Back to conversations"
onClick={() => router.goBack()}
className="grid size-9 place-items-center rounded-full text-muted-foreground hover:bg-muted md:hidden cursor-pointer -ml-2"
>
<ArrowLeftIcon size={19} />
</button>
<Avatar
name={chat?.formattedTitle || chat?.name}
className="size-10 -ml-2"
/>
<div className="min-w-0 flex-1 truncate">
<h2 className="truncate text-sm font-semibold">
{chat?.formattedTitle || chat?.name}
</h2>
{/* <p className="text-xs text-[#1ea66a]">
{selected.online ? "Online now" : "Active recently"}
</p> */}
</div>
<SearchInput
className="w-full max-w-40 md:max-w-sm"
inputClassName="text-xs placeholder:text-xs"
placeholder="Search..."
defaultValue={search}
onValueChange={setSearch}
/>
</div>
)}
</header>
<div
ref={msgContainerRef}
className="message-pattern flex-1 py-6 flex flex-col-reverse overflow-y-auto"
>
{/* <div className="mb-6 flex items-center justify-center gap-2 text-[11px] text-muted-foreground">
<span className="h-px flex-1 bg-border" />
<span>Today</span>
<span className="h-px flex-1 bg-border" />
</div> */}
{messageList?.map((item, index) => (
<div
id={item.id.replace(/^(?:false_|true_)/, "")}
key={`${item.timestamp}-${index}`}
className={cn(
"flex px-4 sm:px-8 py-1.5",
item.fromMe ? "justify-end" : "justify-start",
selectedIds != null && "cursor-pointer hover:bg-[#26855c]/10",
selectedIds?.has(item.id) && "bg-[#26855c]/30!",
)}
role={selectedIds != null ? "button" : undefined}
onClick={
selectedIds != null
? (e) => {
if (e.target !== e.currentTarget) return;
onSelectMsg(item.id);
}
: undefined
}
>
<div className="max-w-[82%] sm:max-w-[65%]">
<a
href="#"
onClick={(e) => {
e.preventDefault();
router.push(
`/?chatId=${getId(item.author || item.from?.id)}`,
);
}}
className="mb-1 text-xs text-muted-foreground"
>
{getFormattedName(item.from) || item.notifyName}
</a>
<div
className={cn(
"rounded-2xl px-4 py-2.5 text-sm shadow-sm relative group rounded-tr-md",
item.fromMe
? "rounded-br-md bg-[#d8f5e8] dark:bg-[#26855c] text-foreground"
: "rounded-bl-md bg-card text-card-foreground",
)}
>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="absolute top-0 right-0 z-1 md:opacity-0 group-hover:opacity-100"
/>
}
>
<ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setReplyTo(item as never)}>
<ReplyIcon />
Reply
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onSelectMsg(item.id)}>
<MousePointer2Icon />
Select
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
copyToClipboard(
item.caption ||
(item.type === "chat" ? item.body : null) ||
"",
)
}
>
<CopyIcon />
Copy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
forwardDlg.onOpen({ messageIds: [item.id] })
}
>
<CornerUpRightIcon />
Forward
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDeleteMsg(item.id)}>
<Trash2Icon />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{item.isForwarded ? (
<p className="text-xs italic mb-1 text-primary/80">
Forwarded
</p>
) : null}
{/* <pre className="overflow-y-auto max-h-80">
{JSON.stringify(item, null, 2)}
</pre> */}
{item.quotedMsg &&
["chat", "image", "document"].includes(
item.quotedMsg.type as string,
) ? (
<QuotedMsg
data={item.quotedMsg as never}
getMentionLabel={getMentionLabel}
onMentionClick={onMentionClick}
/>
) : null}
{item.type === "image" ||
item.type === "sticker" ||
item.type === "document" ||
item.type === "ptt" ? (
<MessageMedia data={item as never} />
) : null}
{item.type === "chat" || item.caption ? (
<Paragraph
text={item.caption || item.body}
getMentionLabel={getMentionLabel}
onMentionClick={onMentionClick}
/>
) : null}
{item.type === "call_log" && (
<div className="flex items-center gap-2 pt-3 pb-2 px-2">
<PhoneCallIcon className="size-5" />
<p className="text-sm italic">Call log</p>
</div>
)}
<div
className={`mt-1 flex items-center justify-end gap-1 text-[10px] ${item.fromMe ? "text-primary" : "text-muted-foreground"}`}
>
<span>{dayjs((item.timestamp ?? 0) * 1000).fromNow()}</span>
{item.fromMe && (
<>
{item.ack && item.ack >= 2 ? (
<CheckCheckIcon
size={14}
className={item.ack === 3 ? "text-blue-300" : ""}
/>
) : (
<CheckIcon size={13} />
)}
</>
)}
</div>
</div>
</div>
</div>
))}
{messages == null || isLoading || isFetchingNextPage
? [...Array(10)].map((_, i) => (
<div
key={i}
className={cn(
"mb-4 shrink-0 px-4 sm:px-8 w-full max-w-[44%]",
i % 2 === 0 ? "self-start" : "self-end",
)}
>
<Skeleton className="w-20 mb-3 h-6 bg-card" />
<Skeleton
className={cn(
"w-full h-20 bg-card rounded-2xl",
["h-20", "h-32", "h-24", "h-40"][i % 4],
)}
/>
</div>
))
: null}
{messages != null && <div ref={endScrollRef} className="h-1" />}
<div className="mb-6 flex items-center justify-center gap-2 text-[11px] text-muted-foreground">
<span className="h-px flex-1 bg-border" />
<div className="flex items-center gap-2">
{isLoading || isFetchingNextPage ? (
<Loader2Icon className="animate-spin size-4" />
) : null}
<span>
{isLoading || isFetchingNextPage
? "Loading..."
: "End of messages"}
</span>
</div>
<span className="h-px flex-1 bg-border" />
</div>
</div>
<MessageInputSection
chatId={chatId}
refetch={refetch}
replyTo={replyTo}
setReplyTo={setReplyTo}
/>
</section>
);
}
function QuotedMsg({
data,
getMentionLabel,
onMentionClick,
className,
}: {
data: MessageDTOWithStrID;
getMentionLabel?: (username: string) => string;
onMentionClick?: (username: string) => void;
className?: string;
}) {
return (
<button
type="button"
onClick={() => {
const id = getId(data.id).replace(/^(?:false_|true_)/, "");
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth" });
}
}}
className={cn(
"bg-card/30 px-2 py-1.5 rounded-lg mb-2 mt-1 cursor-pointer block text-left",
!data.fromMe && "bg-[#d8f5e8]/30 dark:bg-[#26855c]/30",
className,
)}
>
<p className="text-xs text-muted-foreground">
{getFormattedName(data.from)}
</p>
<Paragraph
text={
data.filename ||
data.caption ||
(data.type === "chat" ? data.body : "Quoted")
}
getMentionLabel={getMentionLabel}
onMentionClick={onMentionClick}
/>
</button>
);
}
function MessageInputSection({
chatId,
refetch,
replyTo,
setReplyTo,
getMentionLabel,
}: {
chatId?: string | null;
refetch: () => void;
replyTo?: MessageDTOWithStrID | null;
setReplyTo?: (id: MessageDTOWithStrID | null) => void;
getMentionLabel?: (username: string) => string;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const { client, isReady } = useWAClient();
const [message, setMessage] = useState("");
const [files, setFiles] = useState<{ id: string; file: File }[]>([]);
const [isPending, setPending] = useState(false);
useEffect(() => {
function onDragOver(e: DragEvent) {
e.preventDefault();
}
function onDrop(e: DragEvent) {
const fileList = e.dataTransfer?.files || [];
if (fileList) {
e.preventDefault();
onFileAttached([...fileList]);
}
}
document.addEventListener("dragover", onDragOver);
document.addEventListener("drop", onDrop);
return () => {
document.removeEventListener("dragover", onDragOver);
document.removeEventListener("drop", onDrop);
};
}, []);
function onFileAttached(files: File[]) {
setFiles((f) => [...f, ...files.map((file) => ({ id: nanoid(), file }))]);
}
function onPaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
const fileList = e.clipboardData?.files || [];
if (fileList.length > 0) {
e.preventDefault();
onFileAttached([...fileList]);
}
}
async function sendMessage() {
const text = message.trim();
if ((!text && !files.length) || !chatId || !isReady) return;
try {
setPending(true);
const options = { waitForAck: true, quotedMsg: replyTo?.id || undefined };
if (files.length > 0) {
const fileList = await getFilesAsBase64(files.map((f) => f.file));
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
await client.sendFile({
chatId,
data: file,
options: {
...options,
caption: i === 0 ? text : undefined,
filename: files[i].file.name,
mimetype: files[i].file.type,
},
});
}
} else {
await client.sendText({ chatId, text, options });
}
setTimeout(refetch, 1000);
setFiles([]);
setMessage("");
setReplyTo?.(null);
} catch (err) {
alert((err as Error).message);
} finally {
setPending(false);
}
}
function onRemoveAttachment(id: string) {
setFiles((f) => f.filter((file) => file.id !== id));
}
return (
<div className="border-t border-border bg-card px-3 py-3 sm:py-4">
{replyTo != null && (
<div className="flex items-center gap-1 -mt-2 mb-1">
<ReplyIcon className="size-5" />
<QuotedMsg
data={replyTo}
getMentionLabel={getMentionLabel}
className="text-sm max-h-32 overflow-hidden"
/>
<Button
type="button"
variant="ghost"
onClick={() => setReplyTo?.(null)}
>
<XIcon />
</Button>
</div>
)}
<input
type="file"
ref={fileRef}
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) {
onFileAttached([...e.target.files]);
}
e.target.value = "";
}}
/>
{files.length > 0 && (
<div className="flex items-center gap-2 mb-3 overflow-x-auto">
{files.map((i) => (
<AttachmentPreview
key={i.id}
file={i.file}
onRemove={() => onRemoveAttachment(i.id)}
/>
))}
</div>
)}
<div className="flex items-end gap-2">
<button
aria-label="Add attachment"
className="grid size-10 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-muted-foreground"
onClick={() => fileRef.current?.click()}
>
<PaperclipIcon size={19} />
</button>
<div className="flex min-h-10 flex-1 items-center gap-2 rounded-2xl bg-muted px-3">
{/* <button
aria-label="Add emoji"
className="shrink-0 text-muted-foreground hover:text-muted-foreground"
>
<SmileIcon size={19} />
</button> */}
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
onPaste={onPaste}
enterKeyHint="enter"
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing &&
event.keyCode !== 229
) {
event.preventDefault();
sendMessage();
}
}}
placeholder="Write a message..."
rows={1}
className="max-h-24 min-h-10 flex-1 resize-none field-sizing-content bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<button
aria-label="Send message"
onClick={sendMessage}
className="grid size-10 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!isReady || (!message.trim() && !files.length) || isPending}
>
<SendIcon size={17} />
</button>
</div>
</div>
);
}
function AttachmentPreview({
file,
onRemove,
}: {
file: File;
onRemove?: () => void;
}) {
const [img, setImg] = useState<string | null>(null);
useEffect(() => {
if (!file.type.startsWith("image/")) return;
const blob = URL.createObjectURL(file);
setImg(blob);
return () => URL.revokeObjectURL(blob);
}, [file]);
return (
<div
className={cn(
"flex items-center gap-2 rounded-2xl bg-muted px-3 py-2 shrink-0",
img != null && "rounded-sm",
)}
>
{img ? (
<a href={img} target="_blank" rel="noopener noreferrer">
<img
src={img}
alt={file.name}
className="h-10 aspect-video object-cover"
/>
</a>
) : (
<p className="text-xs">{file.name}</p>
)}
<button
aria-label="Remove attachment"
className="shrink-0 text-muted-foreground cursor-pointer hover:bg-muted hover:text-white"
onClick={onRemove}
>
<XIcon size={16} />
</button>
</div>
);
}