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

369 lines
12 KiB
TypeScript

import { useWAClient } from "./context";
import { getFormattedName, getId } from "@/lib/utils";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { cn } from "cn";
import {
ArrowLeftIcon,
CheckCheckIcon,
CheckIcon,
Loader2Icon,
PaperclipIcon,
PhoneCallIcon,
SendIcon,
} from "lucide-react";
import { useMemo, 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";
dayjs.extend(relativeTime);
type Props = {
chatId: string | null;
maxCount?: number;
};
export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
const { client, isReady } = useWAClient();
const router = useHashRouter();
const [message, setMessage] = useState("");
const [isPending, setPending] = useState(false);
const queryClient = useQueryClient();
const { data: chats } = useChats();
const { data: contacts } = useContacts();
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(),
});
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) {
fetchNextPage();
}
});
const chat = useMemo(
() => chats?.find((c) => c.id === chatId),
[chats, chatId],
);
const messageList = useMemo(() => {
return messages
?.filter((i) => !["revoked"].includes(i.type as never))
?.map((i) => {
const from = contacts?.find(
(c) => getId(c.id) === getId(i.author || i.from),
);
const quotedMsg = i.quotedMsg
? {
...i.quotedMsg,
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]);
async function sendMessage() {
const text = message.trim();
if (!text || !chatId) return;
try {
setPending(true);
await client.sendText({ chatId, text, options: { waitForAck: true } });
setTimeout(refetch, 100);
setMessage("");
} catch (err) {
alert((err as Error).message);
} finally {
setPending(false);
}
}
return (
<section
className={`flex min-w-0 flex-1 flex-col bg-muted ${chatId ? "flex" : "hidden md:flex"}`}
>
<header className="flex items-center justify-between border-b border-border bg-card px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<button
aria-label="Back to conversations"
onClick={() => router.goBack()}
className="grid size-9 place-items-center rounded-full text-muted-foreground hover:bg-muted md:hidden cursor-pointer -ml-2"
>
<ArrowLeftIcon size={19} />
</button>
<Avatar
name={chat?.formattedTitle || chat?.name}
className="size-10"
/>
<div className="min-w-0">
<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>
</div>
{/* <div className="flex items-center gap-1 text-muted-foreground">
<button
aria-label="Start video call"
className="grid size-9 place-items-center rounded-full hover:bg-muted"
>
<VideoIcon size={18} />
</button>
<button
aria-label="Start voice call"
className="grid size-9 place-items-center rounded-full hover:bg-muted"
>
<Phone size={18} />
</button>
<button
aria-label="Chat details"
className="grid size-9 place-items-center rounded-full hover:bg-muted"
>
<EllipsisVertical size={18} />
</button>
</div> */}
</header>
<div className="message-pattern flex-1 px-4 py-6 sm:px-8 flex flex-col-reverse overflow-y-auto">
{/* <div 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={`mb-3 flex ${item.fromMe ? "justify-end" : "justify-start"}`}
>
<div className="max-w-[82%] sm:max-w-[65%]">
<p className="mb-1 text-xs text-muted-foreground">
{getFormattedName(item.from) || item.notifyName}
</p>
<div
className={`rounded-2xl px-4 py-2.5 text-sm shadow-sm ${item.fromMe ? "rounded-br-md bg-[#d8f5e8] dark:bg-[#26855c] text-foreground" : "rounded-bl-md bg-card text-card-foreground"}`}
>
{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,
) ? (
<button
type="button"
onClick={() => {
const id = getId(item.quotedMsgId).replace(
/^(?:false_|true_)/,
"",
);
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth" });
}
}}
className={cn(
"bg-card/30 px-2 py-1.5 rounded-lg mb-2 mt-1 cursor-pointer block",
!item.fromMe && "bg-[#d8f5e8]/30 dark:bg-[#26855c]/30",
)}
>
<p className="text-xs text-muted-foreground">
{getFormattedName(item.quotedMsg.from)}
</p>
<Paragraph
text={
item.quotedMsg.filename ||
item.quotedMsg.caption ||
(item.quotedMsg.type === "chat"
? item.quotedMsg.body
: "Quoted")
}
/>
</button>
) : 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} />
) : 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>
))}
<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" />
))}
<span>
{isLoading || isFetchingNextPage
? "Loading..."
: "End of messages"}
</span>
</div>
<span className="h-px flex-1 bg-border" />
</div>
</div>
<div className="border-t border-border bg-card px-3 py-3 sm:px-6 sm:py-4">
<div className="mx-auto flex max-w-2xl 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"
>
<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)}
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={!message.trim() || isPending}
>
<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>
</section>
);
}