feat: add infinite scroll & fix some ui

This commit is contained in:
2026-09-18 01:10:35 +08:00
parent 65a2e534d7
commit 6e852ab224
9 changed files with 443 additions and 91 deletions
+104 -40
View File
@@ -1,55 +1,97 @@
import { useWAClient } from "./context";
import { getFormattedName, getId } from "@/lib/utils";
import type { ChatDTO } from "@shared/dto";
import { useQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { cn } from "cn";
import {
ArrowLeftIcon,
CheckCheckIcon,
CheckIcon,
Loader2Icon,
PaperclipIcon,
PhoneCallIcon,
SendIcon,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { useMemo, useState } from "react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import MessageMedia from "@/components/message-media";
import Avatar from "@/components/ui/avatar";
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 = {
chat: ChatDTO;
mobileChat: boolean;
setMobileChat: React.Dispatch<React.SetStateAction<boolean>>;
chatId: string | null;
maxCount?: number;
};
export default function MessagesSection({
chat,
mobileChat,
setMobileChat,
}: Props) {
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 chatId = getId(chat.id);
const { data: messages, refetch } = useQuery({
const {
data: messages,
isLoading,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["messages", chatId],
queryFn: () => client.getMessages({ chatId, count: 50 }),
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),
refetchInterval: 10_1000,
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) refetch();
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))
@@ -76,13 +118,12 @@ export default function MessagesSection({
from,
quotedMsg,
};
})
?.reverse();
});
}, [messages, contacts]);
async function sendMessage() {
const text = message.trim();
if (!text) return;
if (!text || !chatId) return;
try {
setPending(true);
@@ -98,23 +139,26 @@ export default function MessagesSection({
return (
<section
className={`flex min-w-0 flex-1 flex-col bg-muted ${mobileChat ? "flex" : "hidden md:flex"}`}
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={() => setMobileChat(false)}
className="grid size-9 place-items-center rounded-full text-muted-foreground hover:bg-muted md:hidden cursor-pointer"
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" />
<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}
{chat?.formattedTitle || chat?.name}
</h2>
{/* <p className="text-xs text-[#1ea66a]">
@@ -153,7 +197,7 @@ export default function MessagesSection({
{messageList?.map((item, index) => (
<div
id={item.id}
id={item.id.replace(/^(?:false_|true_)/, "")}
key={`${item.timestamp}-${index}`}
className={`mb-3 flex ${item.fromMe ? "justify-end" : "justify-start"}`}
>
@@ -178,13 +222,14 @@ export default function MessagesSection({
["chat", "image", "document"].includes(
item.quotedMsg.type as string,
) ? (
<a
href="#"
onClick={(e) => {
e.preventDefault();
const el = document.getElementById(
getId(item.quotedMsgId),
<button
type="button"
onClick={() => {
const id = getId(item.quotedMsgId).replace(
/^(?:false_|true_)/,
"",
);
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth" });
}
@@ -197,14 +242,17 @@ export default function MessagesSection({
<p className="text-xs text-muted-foreground">
{getFormattedName(item.quotedMsg.from)}
</p>
<p className="whitespace-pre-wrap wrap-break-word">
{item.quotedMsg.filename ||
<Paragraph
text={
item.quotedMsg.filename ||
item.quotedMsg.caption ||
(item.quotedMsg.type === "chat"
? item.quotedMsg.body
: "Quoted")}
</p>
</a>
: "Quoted")
}
/>
</button>
) : null}
{item.type === "image" ||
@@ -215,9 +263,7 @@ export default function MessagesSection({
) : null}
{item.type === "chat" || item.caption ? (
<p className="whitespace-pre-wrap wrap-break-word">
{item.caption || item.body}
</p>
<Paragraph text={item.caption || item.body} />
) : null}
{item.type === "call_log" && (
@@ -249,6 +295,24 @@ export default function MessagesSection({
</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">