369 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|