801 lines
24 KiB
TypeScript
801 lines
24 KiB
TypeScript
import { useWAClient } from "./context";
|
|
import {
|
|
copyToClipboard,
|
|
getFilesAsBase64,
|
|
getFormattedName,
|
|
getId,
|
|
} from "@/lib/utils";
|
|
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { cn } from "cn";
|
|
import {
|
|
ArrowLeftIcon,
|
|
CheckCheckIcon,
|
|
CheckIcon,
|
|
ChevronDown,
|
|
CopyIcon,
|
|
CornerUpRightIcon,
|
|
Loader2Icon,
|
|
MousePointer2Icon,
|
|
PaperclipIcon,
|
|
PhoneCallIcon,
|
|
ReplyIcon,
|
|
SendIcon,
|
|
Trash2Icon,
|
|
XIcon,
|
|
} from "lucide-react";
|
|
import { useEffect, useMemo, useRef, 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";
|
|
import { nanoid } from "nanoid";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import SearchInput from "@/components/search-input";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} 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);
|
|
|
|
type Props = {
|
|
chatId: string | null;
|
|
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();
|
|
const msgContainerRef = useRef<HTMLDivElement>(null!);
|
|
const lastFetch = useRef<number>(0);
|
|
|
|
const queryClient = useQueryClient();
|
|
const { data: chats } = useChats();
|
|
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,
|
|
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(),
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (chatId) {
|
|
msgContainerRef.current.scrollTop = 0;
|
|
setSelectedIds(null);
|
|
}
|
|
}, [chatId]);
|
|
|
|
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) return;
|
|
|
|
const t = Date.now() - lastFetch.current;
|
|
if (t < 1000) return;
|
|
|
|
fetchNextPage().then(() => {
|
|
lastFetch.current = Date.now();
|
|
msgContainerRef.current.scrollTop -= 10;
|
|
});
|
|
});
|
|
|
|
const chat = useMemo(
|
|
() => chats?.find((c) => c.id === chatId),
|
|
[chats, chatId],
|
|
);
|
|
|
|
const messageList = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
|
|
return messages
|
|
?.filter((i) => {
|
|
if (["revoked"].includes(i.type as never)) {
|
|
return false;
|
|
}
|
|
|
|
if (q) {
|
|
const body = i.caption || (i.type === "chat" ? i.body : "") || "";
|
|
const name = getFormattedName(i.from) || "";
|
|
return (
|
|
body.toLowerCase().includes(q) ||
|
|
name.toLowerCase().includes(q) ||
|
|
i.filename?.toLowerCase().includes(q)
|
|
);
|
|
}
|
|
|
|
return true;
|
|
})
|
|
?.map((i) => {
|
|
const from = contacts?.find(
|
|
(c) => getId(c.id) === getId(i.author || i.from),
|
|
);
|
|
|
|
const quotedMsg = i.quotedMsg
|
|
? {
|
|
...i.quotedMsg,
|
|
fromMe: i.fromMe,
|
|
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, search]);
|
|
|
|
function onSelectMsg(id: string) {
|
|
setSelectedIds((s) => {
|
|
const set = new Set(s || []);
|
|
if (!set.has(id)) {
|
|
set.add(id);
|
|
} else {
|
|
set.delete(id);
|
|
}
|
|
return !set.size ? null : set;
|
|
});
|
|
}
|
|
|
|
async function onDeleteMsg(id: string | string[]) {
|
|
if (!window.confirm("Delete this message?")) return;
|
|
try {
|
|
await client.exec(
|
|
`return WPP.chat.deleteMessage('${chatId}', ${JSON.stringify(id)}, true, true);`,
|
|
);
|
|
refetch();
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
function getMentionLabel(username: string) {
|
|
const data = contacts?.find((c) => (c.id as any).user === username);
|
|
return data?.notifyName || data?.formattedName || data?.name || username;
|
|
}
|
|
|
|
function onMentionClick(username: string) {
|
|
const data = contacts?.find((c) => (c.id as any).user === username);
|
|
if (data) {
|
|
router.push(`/?chatId=${getId(data.id)}`);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section
|
|
className={`flex min-w-0 h-full 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">
|
|
{selectedIds?.size ? (
|
|
<div className="flex items-center gap-3 w-full">
|
|
<button
|
|
aria-label="Cancel selection"
|
|
onClick={() => setSelectedIds(null)}
|
|
className="grid size-10 place-items-center rounded-full text-muted-foreground hover:bg-muted cursor-pointer -ml-2"
|
|
>
|
|
<XIcon size={19} />
|
|
</button>
|
|
<p className="text-sm font-medium truncate">{`${selectedIds.size} selected`}</p>
|
|
<div className="flex-1" />
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-lg"
|
|
className="rounded-full"
|
|
title="Forward"
|
|
onClick={() => {
|
|
forwardDlg.onOpen({ messageIds: [...selectedIds] });
|
|
setSelectedIds(null);
|
|
}}
|
|
>
|
|
<CornerUpRightIcon />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-lg"
|
|
className="rounded-full"
|
|
title="Delete"
|
|
onClick={() => {
|
|
onDeleteMsg([...selectedIds]);
|
|
setSelectedIds(null);
|
|
}}
|
|
>
|
|
<Trash2Icon />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex min-w-0 items-center gap-3 w-full">
|
|
<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 -ml-2"
|
|
/>
|
|
|
|
<div className="min-w-0 flex-1 truncate">
|
|
<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>
|
|
|
|
<SearchInput
|
|
className="w-full max-w-40 md:max-w-sm"
|
|
inputClassName="text-xs placeholder:text-xs"
|
|
placeholder="Search..."
|
|
defaultValue={search}
|
|
onValueChange={setSearch}
|
|
/>
|
|
</div>
|
|
)}
|
|
</header>
|
|
|
|
<div
|
|
ref={msgContainerRef}
|
|
className="message-pattern flex-1 py-6 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={cn(
|
|
"flex px-4 sm:px-8 py-1.5",
|
|
item.fromMe ? "justify-end" : "justify-start",
|
|
selectedIds != null && "cursor-pointer hover:bg-[#26855c]/10",
|
|
selectedIds?.has(item.id) && "bg-[#26855c]/30!",
|
|
)}
|
|
role={selectedIds != null ? "button" : undefined}
|
|
onClick={
|
|
selectedIds != null
|
|
? (e) => {
|
|
if (e.target !== e.currentTarget) return;
|
|
onSelectMsg(item.id);
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
<div className="max-w-[82%] sm:max-w-[65%]">
|
|
<a
|
|
href="#"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
router.push(
|
|
`/?chatId=${getId(item.author || item.from?.id)}`,
|
|
);
|
|
}}
|
|
className="mb-1 text-xs text-muted-foreground"
|
|
>
|
|
{getFormattedName(item.from) || item.notifyName}
|
|
</a>
|
|
<div
|
|
className={cn(
|
|
"rounded-2xl px-4 py-2.5 text-sm shadow-sm relative group rounded-tr-md",
|
|
item.fromMe
|
|
? "rounded-br-md bg-[#d8f5e8] dark:bg-[#26855c] text-foreground"
|
|
: "rounded-bl-md bg-card text-card-foreground",
|
|
)}
|
|
>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="absolute top-0 right-0 z-1 md:opacity-0 group-hover:opacity-100"
|
|
/>
|
|
}
|
|
>
|
|
<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] })
|
|
}
|
|
>
|
|
<CornerUpRightIcon />
|
|
Forward
|
|
</DropdownMenuItem>
|
|
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => onDeleteMsg(item.id)}>
|
|
<Trash2Icon />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{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,
|
|
) ? (
|
|
<QuotedMsg
|
|
data={item.quotedMsg as never}
|
|
getMentionLabel={getMentionLabel}
|
|
onMentionClick={onMentionClick}
|
|
/>
|
|
) : 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}
|
|
getMentionLabel={getMentionLabel}
|
|
onMentionClick={onMentionClick}
|
|
/>
|
|
) : 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>
|
|
))}
|
|
|
|
{messages == null || isLoading || isFetchingNextPage
|
|
? [...Array(10)].map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
"mb-4 shrink-0 px-4 sm:px-8 w-full max-w-[44%]",
|
|
i % 2 === 0 ? "self-start" : "self-end",
|
|
)}
|
|
>
|
|
<Skeleton className="w-20 mb-3 h-6 bg-card" />
|
|
<Skeleton
|
|
className={cn(
|
|
"w-full h-20 bg-card rounded-2xl",
|
|
["h-20", "h-32", "h-24", "h-40"][i % 4],
|
|
)}
|
|
/>
|
|
</div>
|
|
))
|
|
: null}
|
|
|
|
{messages != null && <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" />
|
|
) : null}
|
|
<span>
|
|
{isLoading || isFetchingNextPage
|
|
? "Loading..."
|
|
: "End of messages"}
|
|
</span>
|
|
</div>
|
|
<span className="h-px flex-1 bg-border" />
|
|
</div>
|
|
</div>
|
|
|
|
<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();
|
|
const [message, setMessage] = useState("");
|
|
const [files, setFiles] = useState<{ id: string; file: File }[]>([]);
|
|
const [isPending, setPending] = useState(false);
|
|
|
|
useEffect(() => {
|
|
function onDragOver(e: DragEvent) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
function onDrop(e: DragEvent) {
|
|
const fileList = e.dataTransfer?.files || [];
|
|
if (fileList) {
|
|
e.preventDefault();
|
|
onFileAttached([...fileList]);
|
|
}
|
|
}
|
|
|
|
document.addEventListener("dragover", onDragOver);
|
|
document.addEventListener("drop", onDrop);
|
|
|
|
return () => {
|
|
document.removeEventListener("dragover", onDragOver);
|
|
document.removeEventListener("drop", onDrop);
|
|
};
|
|
}, []);
|
|
|
|
function onFileAttached(files: File[]) {
|
|
setFiles((f) => [...f, ...files.map((file) => ({ id: nanoid(), file }))]);
|
|
}
|
|
|
|
function onPaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
|
|
const fileList = e.clipboardData?.files || [];
|
|
if (fileList.length > 0) {
|
|
e.preventDefault();
|
|
onFileAttached([...fileList]);
|
|
}
|
|
}
|
|
|
|
async function sendMessage() {
|
|
const text = message.trim();
|
|
if ((!text && !files.length) || !chatId || !isReady) return;
|
|
|
|
try {
|
|
setPending(true);
|
|
const options = { waitForAck: true, quotedMsg: replyTo?.id || undefined };
|
|
|
|
if (files.length > 0) {
|
|
const fileList = await getFilesAsBase64(files.map((f) => f.file));
|
|
for (let i = 0; i < fileList.length; i++) {
|
|
const file = fileList[i];
|
|
await client.sendFile({
|
|
chatId,
|
|
data: file,
|
|
options: {
|
|
...options,
|
|
caption: i === 0 ? text : undefined,
|
|
filename: files[i].file.name,
|
|
mimetype: files[i].file.type,
|
|
},
|
|
});
|
|
}
|
|
} else {
|
|
await client.sendText({ chatId, text, options });
|
|
}
|
|
|
|
setTimeout(refetch, 1000);
|
|
setFiles([]);
|
|
setMessage("");
|
|
setReplyTo?.(null);
|
|
} catch (err) {
|
|
alert((err as Error).message);
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
function onRemoveAttachment(id: string) {
|
|
setFiles((f) => f.filter((file) => file.id !== id));
|
|
}
|
|
|
|
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 max-h-32 overflow-hidden"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => setReplyTo?.(null)}
|
|
>
|
|
<XIcon />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<input
|
|
type="file"
|
|
ref={fileRef}
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
if (e.target.files?.length) {
|
|
onFileAttached([...e.target.files]);
|
|
}
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
|
|
{files.length > 0 && (
|
|
<div className="flex items-center gap-2 mb-3 overflow-x-auto">
|
|
{files.map((i) => (
|
|
<AttachmentPreview
|
|
key={i.id}
|
|
file={i.file}
|
|
onRemove={() => onRemoveAttachment(i.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex 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"
|
|
onClick={() => fileRef.current?.click()}
|
|
>
|
|
<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)}
|
|
onPaste={onPaste}
|
|
enterKeyHint="enter"
|
|
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={!isReady || (!message.trim() && !files.length) || isPending}
|
|
>
|
|
<SendIcon size={17} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AttachmentPreview({
|
|
file,
|
|
onRemove,
|
|
}: {
|
|
file: File;
|
|
onRemove?: () => void;
|
|
}) {
|
|
const [img, setImg] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!file.type.startsWith("image/")) return;
|
|
const blob = URL.createObjectURL(file);
|
|
setImg(blob);
|
|
return () => URL.revokeObjectURL(blob);
|
|
}, [file]);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-2 rounded-2xl bg-muted px-3 py-2 shrink-0",
|
|
img != null && "rounded-sm",
|
|
)}
|
|
>
|
|
{img ? (
|
|
<a href={img} target="_blank" rel="noopener noreferrer">
|
|
<img
|
|
src={img}
|
|
alt={file.name}
|
|
className="h-10 aspect-video object-cover"
|
|
/>
|
|
</a>
|
|
) : (
|
|
<p className="text-xs">{file.name}</p>
|
|
)}
|
|
<button
|
|
aria-label="Remove attachment"
|
|
className="shrink-0 text-muted-foreground cursor-pointer hover:bg-muted hover:text-white"
|
|
onClick={onRemove}
|
|
>
|
|
<XIcon size={16} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|