feat(chat): add in-message attachments gallery and multi-file send
All checks were successful
CI / test (push) Successful in 19s

This commit is contained in:
2026-03-08 13:06:00 +03:00
parent 65d8a9379b
commit d2dd9aa01b
3 changed files with 344 additions and 104 deletions

View File

@@ -37,7 +37,7 @@ export function MessageComposer() {
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file");
const [sendAsCircle, setSendAsCircle] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
@@ -456,57 +456,106 @@ export function MessageComposer() {
window.addEventListener("pointercancel", onPointerCancel, { once: true });
}
function selectFile(file: File) {
function selectFiles(files: File[]) {
if (!files.length) {
return;
}
setUploadError(null);
setSelectedFile(file);
setSelectedFiles(files);
setShowAttachMenu(false);
const fileType = inferType(file);
const fileType = inferType(files[0]);
setSelectedType(fileType);
setSendAsCircle(false);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
if (fileType === "image" || fileType === "video") {
setPreviewUrl(URL.createObjectURL(file));
if (files.length === 1 && (fileType === "image" || fileType === "video")) {
setPreviewUrl(URL.createObjectURL(files[0]));
} else {
setPreviewUrl(null);
}
}
async function sendSelectedFile() {
if (!selectedFile) {
async function sendSelectedFiles() {
if (!selectedFiles.length || !activeChatId || !me) {
return;
}
const uploadFile = await prepareFileForUpload(selectedFile, selectedType);
const messageType = selectedType === "video" && sendAsCircle ? "circle_video" : selectedType;
await handleUpload(uploadFile, messageType);
if (captionDraft.trim() && activeChatId && me) {
const clientMessageId = makeClientMessageId();
const textValue = captionDraft.trim();
addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "text", text: textValue, clientMessageId });
try {
const message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId);
confirmMessageByClientId(activeChatId, clientMessageId, message);
} catch {
removeOptimisticMessage(activeChatId, clientMessageId);
setIsUploading(true);
setUploadError(null);
setUploadProgress(0);
const prepared = await Promise.all(
selectedFiles.map(async (file) => {
const kind = inferType(file);
const uploadFile = await prepareFileForUpload(file, kind);
return { file: uploadFile, kind };
})
);
const uploaded: Array<{ fileUrl: string; fileType: string; fileSize: number; kind: "file" | "image" | "video" | "audio" }> = [];
try {
for (let index = 0; index < prepared.length; index += 1) {
const current = prepared[index];
const upload = await requestUploadUrl(current.file);
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, current.file, (percent) => {
const done = index / prepared.length;
const currentPart = (percent / 100) / prepared.length;
setUploadProgress(Math.min(100, Math.round((done + currentPart) * 100)));
});
uploaded.push({
fileUrl: upload.file_url,
fileType: current.file.type || "application/octet-stream",
fileSize: current.file.size,
kind: current.kind,
});
}
const kindSet = new Set(uploaded.map((item) => item.kind));
const inferredType: "file" | "image" | "video" | "audio" =
kindSet.size === 1 ? uploaded[0].kind : "file";
const messageType =
inferredType === "video" && uploaded.length === 1 && sendAsCircle ? "circle_video" : inferredType;
const caption = captionDraft.trim() || null;
const fallbackText = uploaded[0]?.fileUrl ?? null;
const clientMessageId = makeClientMessageId();
addOptimisticMessage({
chatId: activeChatId,
senderId: me.id,
type: messageType,
text: caption || fallbackText,
clientMessageId,
});
const replyToMessageId = replyToByChat[activeChatId]?.id ?? undefined;
const created = await sendMessageWithClientId(
activeChatId,
caption || fallbackText || "",
messageType,
clientMessageId,
replyToMessageId
);
confirmMessageByClientId(activeChatId, clientMessageId, created);
for (const item of uploaded) {
await attachFile(created.id, item.fileUrl, item.fileType, item.fileSize);
}
setReplyToMessage(activeChatId, null);
} catch {
setUploadError("Upload failed. Please try again.");
} finally {
setIsUploading(false);
setUploadProgress(0);
}
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setSelectedFile(null);
setSelectedFiles([]);
setPreviewUrl(null);
setCaptionDraft("");
setSelectedType("file");
setSendAsCircle(false);
setUploadProgress(0);
}
function cancelSelectedFile() {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setSelectedFile(null);
setSelectedFiles([]);
setPreviewUrl(null);
setCaptionDraft("");
setSelectedType("file");
@@ -670,12 +719,13 @@ export function MessageComposer() {
ref={mediaInputRef}
className="hidden"
type="file"
multiple
accept="image/*,video/*"
disabled={isUploading || recordingState !== "idle"}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
selectFile(file);
const files = event.target.files ? Array.from(event.target.files) : [];
if (files.length) {
selectFiles(files);
}
event.currentTarget.value = "";
}}
@@ -684,11 +734,12 @@ export function MessageComposer() {
ref={fileInputRef}
className="hidden"
type="file"
multiple
disabled={isUploading || recordingState !== "idle"}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
selectFile(file);
const files = event.target.files ? Array.from(event.target.files) : [];
if (files.length) {
selectFiles(files);
}
event.currentTarget.value = "";
}}
@@ -747,13 +798,17 @@ export function MessageComposer() {
)}
</div>
{selectedFile ? (
{selectedFiles.length > 0 ? (
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/80 p-3" onClick={cancelSelectedFile}>
<div className="flex h-full w-full max-w-2xl flex-col rounded-2xl border border-slate-700/80 bg-slate-900/95 shadow-2xl" onClick={(event) => event.stopPropagation()}>
<div className="flex items-center justify-between border-b border-slate-700/70 px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">Send {selectedType === "image" || selectedType === "video" ? "Photo" : "File"}</p>
<p className="truncate text-xs text-slate-400">{selectedFile.name} {formatBytes(selectedFile.size)}</p>
<p className="truncate text-sm font-semibold">Send {selectedFiles.length > 1 ? "Attachments" : (selectedType === "image" || selectedType === "video" ? "Photo" : "File")}</p>
<p className="truncate text-xs text-slate-400">
{selectedFiles.length === 1
? `${selectedFiles[0].name}${formatBytes(selectedFiles[0].size)}`
: `${selectedFiles.length} files • ${formatBytes(selectedFiles.reduce((acc, file) => acc + file.size, 0))}`}
</p>
</div>
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={cancelSelectedFile} type="button">
Close
@@ -761,15 +816,18 @@ export function MessageComposer() {
</div>
<div className="tg-scrollbar min-h-0 flex-1 overflow-auto p-3">
{previewUrl && selectedType === "image" ? (
<img className="mx-auto max-h-[70vh] w-full rounded-xl object-contain" src={previewUrl} alt={selectedFile.name} />
{previewUrl && selectedType === "image" && selectedFiles.length === 1 ? (
<img className="mx-auto max-h-[70vh] w-full rounded-xl object-contain" src={previewUrl} alt={selectedFiles[0].name} />
) : null}
{previewUrl && selectedType === "video" ? (
{previewUrl && selectedType === "video" && selectedFiles.length === 1 ? (
<video className="mx-auto max-h-[70vh] w-full rounded-xl" src={previewUrl} controls muted />
) : null}
{!previewUrl ? (
<div className="rounded-lg border border-slate-700/80 bg-slate-800/60 px-3 py-2 text-xs text-slate-300">
No preview available
{selectedFiles.slice(0, 8).map((file) => (
<p className="truncate" key={`${file.name}-${file.size}`}>{file.name}</p>
))}
{selectedFiles.length > 8 ? <p className="mt-1 text-slate-400">+{selectedFiles.length - 8} more</p> : null}
</div>
) : null}
</div>
@@ -782,7 +840,7 @@ export function MessageComposer() {
value={captionDraft}
onChange={(event) => setCaptionDraft(event.target.value)}
/>
{selectedType === "video" ? (
{selectedType === "video" && selectedFiles.length === 1 ? (
<label className="mb-2 flex items-center gap-2 text-xs text-slate-300">
<input
checked={sendAsCircle}
@@ -804,7 +862,7 @@ export function MessageComposer() {
<div className="flex gap-2">
<button
className="w-full rounded-xl bg-sky-500 px-3 py-2 font-semibold text-slate-950 disabled:opacity-50"
onClick={() => void sendSelectedFile()}
onClick={() => void sendSelectedFiles()}
disabled={isUploading}
>
Send