feat: add reply msg

This commit is contained in:
2026-09-21 09:21:18 +08:00
parent 0ece6bbb63
commit 6513dc39b6
2 changed files with 129 additions and 36 deletions
+119 -36
View File
@@ -1,5 +1,10 @@
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 { cn } from "cn";
import {
@@ -7,11 +12,13 @@ import {
CheckCheckIcon,
CheckIcon,
ChevronDown,
CopyIcon,
CornerUpRightIcon,
Loader2Icon,
MousePointer2Icon,
PaperclipIcon,
PhoneCallIcon,
ReplyIcon,
SendIcon,
Trash2Icon,
XIcon,
@@ -39,6 +46,7 @@ import {
} 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);
@@ -47,6 +55,11 @@ type Props = {
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();
@@ -58,6 +71,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
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,
@@ -153,6 +167,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
const quotedMsg = i.quotedMsg
? {
...i.quotedMsg,
fromMe: i.fromMe,
from: contacts?.find((c) => {
return (
getId(c.id) ===
@@ -349,10 +364,31 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
<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] })
@@ -361,6 +397,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
<CornerUpRightIcon />
Forward
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDeleteMsg(item.id)}>
<Trash2Icon />
@@ -383,39 +420,11 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
["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 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>
<QuotedMsg
data={item.quotedMsg as never}
getMentionLabel={getMentionLabel}
onMentionClick={onMentionClick}
/>
) : null}
{item.type === "image" ||
@@ -501,17 +510,72 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
</div>
</div>
<MessageInputSection chatId={chatId} refetch={refetch} />
<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();
@@ -559,7 +623,7 @@ function MessageInputSection({
try {
setPending(true);
const options = { waitForAck: true };
const options = { waitForAck: true, quotedMsg: replyTo?.id || undefined };
if (files.length > 0) {
const fileList = await getFilesAsBase64(files.map((f) => f.file));
@@ -583,6 +647,7 @@ function MessageInputSection({
setTimeout(refetch, 1000);
setFiles([]);
setMessage("");
setReplyTo?.(null);
} catch (err) {
alert((err as Error).message);
} finally {
@@ -596,6 +661,24 @@ function MessageInputSection({
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"
/>
<Button
type="button"
variant="ghost"
onClick={() => setReplyTo?.(null)}
>
<XIcon />
</Button>
</div>
)}
<input
type="file"
ref={fileRef}
+10
View File
@@ -59,3 +59,13 @@ export function createDisclosure<T = any>(data?: T | null) {
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;
}
}