feat: add reply msg
This commit is contained in:
+119
-36
@@ -1,5 +1,10 @@
|
|||||||
import { useWAClient } from "./context";
|
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 { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { cn } from "cn";
|
import { cn } from "cn";
|
||||||
import {
|
import {
|
||||||
@@ -7,11 +12,13 @@ import {
|
|||||||
CheckCheckIcon,
|
CheckCheckIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
CopyIcon,
|
||||||
CornerUpRightIcon,
|
CornerUpRightIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
MousePointer2Icon,
|
MousePointer2Icon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
PhoneCallIcon,
|
PhoneCallIcon,
|
||||||
|
ReplyIcon,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
XIcon,
|
XIcon,
|
||||||
@@ -39,6 +46,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { forwardDlg } from "./stores";
|
import { forwardDlg } from "./stores";
|
||||||
|
import type { ContactDTO, MessageDTO } from "@shared/dto";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
@@ -47,6 +55,11 @@ type Props = {
|
|||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MessageDTOWithStrID = Omit<MessageDTO, "id" | "from"> & {
|
||||||
|
id: string;
|
||||||
|
from?: ContactDTO;
|
||||||
|
};
|
||||||
|
|
||||||
export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
||||||
const { client, isReady } = useWAClient();
|
const { client, isReady } = useWAClient();
|
||||||
const router = useHashRouter();
|
const router = useHashRouter();
|
||||||
@@ -58,6 +71,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
|||||||
const { data: contacts } = useContacts();
|
const { data: contacts } = useContacts();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string> | null>(null);
|
const [selectedIds, setSelectedIds] = useState<Set<string> | null>(null);
|
||||||
|
const [replyTo, setReplyTo] = useState<MessageDTOWithStrID | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: messages,
|
data: messages,
|
||||||
@@ -153,6 +167,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
|||||||
const quotedMsg = i.quotedMsg
|
const quotedMsg = i.quotedMsg
|
||||||
? {
|
? {
|
||||||
...i.quotedMsg,
|
...i.quotedMsg,
|
||||||
|
fromMe: i.fromMe,
|
||||||
from: contacts?.find((c) => {
|
from: contacts?.find((c) => {
|
||||||
return (
|
return (
|
||||||
getId(c.id) ===
|
getId(c.id) ===
|
||||||
@@ -349,10 +364,31 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
|||||||
<ChevronDown />
|
<ChevronDown />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setReplyTo(item as never)}>
|
||||||
|
<ReplyIcon />
|
||||||
|
Reply
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => onSelectMsg(item.id)}>
|
<DropdownMenuItem onClick={() => onSelectMsg(item.id)}>
|
||||||
<MousePointer2Icon />
|
<MousePointer2Icon />
|
||||||
Select
|
Select
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
copyToClipboard(
|
||||||
|
item.caption ||
|
||||||
|
(item.type === "chat" ? item.body : null) ||
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
Copy
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
forwardDlg.onOpen({ messageIds: [item.id] })
|
forwardDlg.onOpen({ messageIds: [item.id] })
|
||||||
@@ -361,6 +397,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
|||||||
<CornerUpRightIcon />
|
<CornerUpRightIcon />
|
||||||
Forward
|
Forward
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => onDeleteMsg(item.id)}>
|
<DropdownMenuItem onClick={() => onDeleteMsg(item.id)}>
|
||||||
<Trash2Icon />
|
<Trash2Icon />
|
||||||
@@ -383,39 +420,11 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
|||||||
["chat", "image", "document"].includes(
|
["chat", "image", "document"].includes(
|
||||||
item.quotedMsg.type as string,
|
item.quotedMsg.type as string,
|
||||||
) ? (
|
) ? (
|
||||||
<button
|
<QuotedMsg
|
||||||
type="button"
|
data={item.quotedMsg as never}
|
||||||
onClick={() => {
|
getMentionLabel={getMentionLabel}
|
||||||
const id = getId(item.quotedMsgId).replace(
|
onMentionClick={onMentionClick}
|
||||||
/^(?: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>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{item.type === "image" ||
|
{item.type === "image" ||
|
||||||
@@ -501,17 +510,72 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MessageInputSection chatId={chatId} refetch={refetch} />
|
<MessageInputSection
|
||||||
|
chatId={chatId}
|
||||||
|
refetch={refetch}
|
||||||
|
replyTo={replyTo}
|
||||||
|
setReplyTo={setReplyTo}
|
||||||
|
/>
|
||||||
</section>
|
</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({
|
function MessageInputSection({
|
||||||
chatId,
|
chatId,
|
||||||
refetch,
|
refetch,
|
||||||
|
replyTo,
|
||||||
|
setReplyTo,
|
||||||
|
getMentionLabel,
|
||||||
}: {
|
}: {
|
||||||
chatId?: string | null;
|
chatId?: string | null;
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
|
replyTo?: MessageDTOWithStrID | null;
|
||||||
|
setReplyTo?: (id: MessageDTOWithStrID | null) => void;
|
||||||
|
getMentionLabel?: (username: string) => string;
|
||||||
}) {
|
}) {
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const { client, isReady } = useWAClient();
|
const { client, isReady } = useWAClient();
|
||||||
@@ -559,7 +623,7 @@ function MessageInputSection({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setPending(true);
|
setPending(true);
|
||||||
const options = { waitForAck: true };
|
const options = { waitForAck: true, quotedMsg: replyTo?.id || undefined };
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
const fileList = await getFilesAsBase64(files.map((f) => f.file));
|
const fileList = await getFilesAsBase64(files.map((f) => f.file));
|
||||||
@@ -583,6 +647,7 @@ function MessageInputSection({
|
|||||||
setTimeout(refetch, 1000);
|
setTimeout(refetch, 1000);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setMessage("");
|
setMessage("");
|
||||||
|
setReplyTo?.(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert((err as Error).message);
|
alert((err as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -596,6 +661,24 @@ function MessageInputSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border bg-card px-3 py-3 sm:py-4">
|
<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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
|
|||||||
@@ -59,3 +59,13 @@ export function createDisclosure<T = any>(data?: T | null) {
|
|||||||
useStore: () => useStore(store),
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user