From d2dd9aa01b3b039031affd3dca57202a5c72b0a5 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 13:06:00 +0300 Subject: [PATCH] feat(chat): add in-message attachments gallery and multi-file send --- app/media/router.py | 8 +- web/src/components/MessageComposer.tsx | 134 ++++++++--- web/src/components/MessageList.tsx | 306 +++++++++++++++++++------ 3 files changed, 344 insertions(+), 104 deletions(-) diff --git a/app/media/router.py b/app/media/router.py index 61af20e..d93c07d 100644 --- a/app/media/router.py +++ b/app/media/router.py @@ -3,8 +3,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.service import get_current_user from app.database.session import get_db +from app.messages.repository import get_message_by_id from app.media.schemas import AttachmentCreateRequest, AttachmentRead, ChatAttachmentRead, UploadUrlRequest, UploadUrlResponse from app.media.service import generate_upload_url, list_attachments_for_chat, store_attachment_metadata +from app.realtime.service import realtime_gateway from app.users.models import User router = APIRouter(prefix="/media", tags=["media"]) @@ -24,7 +26,11 @@ async def create_attachment_metadata( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ) -> AttachmentRead: - return await store_attachment_metadata(db, user_id=current_user.id, payload=payload) + attachment = await store_attachment_metadata(db, user_id=current_user.id, payload=payload) + message = await get_message_by_id(db, attachment.message_id) + if message: + await realtime_gateway.publish_chat_updated(chat_id=message.chat_id) + return attachment @router.get("/chats/{chat_id}/attachments", response_model=list[ChatAttachmentRead]) diff --git a/web/src/components/MessageComposer.tsx b/web/src/components/MessageComposer.tsx index abe6b48..b961507 100644 --- a/web/src/components/MessageComposer.tsx +++ b/web/src/components/MessageComposer.tsx @@ -37,7 +37,7 @@ export function MessageComposer() { const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); - const [selectedFile, setSelectedFile] = useState(null); + const [selectedFiles, setSelectedFiles] = useState([]); const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file"); const [sendAsCircle, setSendAsCircle] = useState(false); const [previewUrl, setPreviewUrl] = useState(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() { )} - {selectedFile ? ( + {selectedFiles.length > 0 ? (
event.stopPropagation()}>
-

Send {selectedType === "image" || selectedType === "video" ? "Photo" : "File"}

-

{selectedFile.name} • {formatBytes(selectedFile.size)}

+

Send {selectedFiles.length > 1 ? "Attachments" : (selectedType === "image" || selectedType === "video" ? "Photo" : "File")}

+

+ {selectedFiles.length === 1 + ? `${selectedFiles[0].name} • ${formatBytes(selectedFiles[0].size)}` + : `${selectedFiles.length} files • ${formatBytes(selectedFiles.reduce((acc, file) => acc + file.size, 0))}`} +

- {previewUrl && selectedType === "image" ? ( - {selectedFile.name} + {previewUrl && selectedType === "image" && selectedFiles.length === 1 ? ( + {selectedFiles[0].name} ) : null} - {previewUrl && selectedType === "video" ? ( + {previewUrl && selectedType === "video" && selectedFiles.length === 1 ? (
@@ -782,7 +840,7 @@ export function MessageComposer() { value={captionDraft} onChange={(event) => setCaptionDraft(event.target.value)} /> - {selectedType === "video" ? ( + {selectedType === "video" && selectedFiles.length === 1 ? (