From 6066ec9767e683a24ddf3d39f2f33955f0b410e4 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 24 Sep 2025 14:14:59 +0300 Subject: [PATCH] Implement file sending --- .cursor/mcp.json | 8 + backend/models.py | 15 ++ backend/routes/messaging.py | 232 +++++++++++++++++- frontend/src/api/dmApi.ts | 2 +- frontend/src/core/types.d.ts | 9 + frontend/src/resources/css/_chat.scss | 126 ++++++---- .../ui/components/chat/ChatInputWrapper.tsx | 72 +++++- frontend/src/ui/components/chat/Message.tsx | 19 ++ .../components/chat/MessagePanelRenderer.tsx | 4 +- frontend/src/ui/panels/DMPanel.ts | 28 ++- frontend/src/ui/panels/MessagePanel.ts | 8 +- frontend/src/ui/panels/PublicChatPanel.ts | 43 ++-- frontend/src/utils/material.ts | 1 + 13 files changed, 472 insertions(+), 95 deletions(-) create mode 100644 .cursor/mcp.json diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..967756d --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "mdui": { + "command": "npx", + "args": ["-y", "@mdui/mcp"] + } + } +} \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index d9e2757..13bec2f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -36,6 +36,21 @@ class Message(Base): author = relationship("User", back_populates="messages") reply_to = relationship("Message", remote_side=[id]) + files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select") + + +class MessageFile(Base): + __tablename__ = "message_file" + + id = Column(Integer, primary_key=True, index=True) + message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True) + path = Column(Text, nullable=False) + encrypted = Column(Boolean, default=False, nullable=False) + filename = Column(String(255), nullable=True) + content_type = Column(String(255), nullable=True) + size = Column(Integer, nullable=True) + + message = relationship("Message", back_populates="files") class CryptoPublicKey(Base): diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 8780edd..81b83f6 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -1,16 +1,35 @@ from datetime import datetime import logging -from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect +from pathlib import Path +import os +import re +import uuid +from typing import Iterable +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form +from fastapi.responses import FileResponse from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy.orm import Session from dependencies import get_current_user, get_db from constants import OWNER_USERNAME -from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope +from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile from push_service import push_service +from PIL import Image +import io +import json router = APIRouter() logger = logging.getLogger("uvicorn.error") +MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB + +FILES_BASE_DIR = Path("data/uploads/files") +FILES_NORMAL_DIR = FILES_BASE_DIR / "normal" +FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted" + +os.makedirs(FILES_NORMAL_DIR, exist_ok=True) +os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True) + + def convert_message(msg: Message) -> dict: return { "id": msg.id, @@ -20,16 +39,40 @@ def convert_message(msg: Message) -> dict: "is_edited": msg.is_edited, "username": msg.author.username, "profile_picture": msg.author.profile_picture, - "reply_to": convert_message(msg.reply_to) if msg.reply_to else None + "reply_to": convert_message(msg.reply_to) if msg.reply_to else None, + "files": [ + { + "path": f"/api/files/{'encrypted' if f.encrypted else 'normal'}/{Path(f.path).name}", + "encrypted": f.encrypted, + "filename": f.filename, + "content_type": f.content_type, + "size": f.size, + } + for f in (msg.files or []) + ] } @router.post("/send_message") async def send_message( - request: SendMessageRequest, + request: SendMessageRequest | None = None, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + # Optional multipart form support + payload: str | None = Form(default=None), + files: list[UploadFile] = File(default=[]), ): + # If payload is provided, prefer it for multipart requests + if payload and request is None: + # Expect JSON: {"type":"text","data":{"content": str}, "reply_to_id": number|null} + try: + obj = json.loads(payload) + content = obj.get("data", {}).get("content", "") + reply_to_id = obj.get("reply_to_id", None) + request = SendMessageRequest(content=content, reply_to_id=reply_to_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid payload JSON") + if request.reply_to_id: # Check if the message being replied to exists original_message = db.query(Message).filter(Message.id == request.reply_to_id).first() @@ -59,12 +102,80 @@ async def send_message( db.commit() db.refresh(new_message) + # Handle files if provided (normal, not encrypted) + if files: + total_size = 0 + for up in files: + # Accumulate size if available + if hasattr(up, "size") and up.size is not None: + total_size += int(up.size) + else: + # If size unknown, read into memory to determine + data = await up.read() + up.file.seek(0) + total_size += len(data) + if total_size > MAX_TOTAL_SIZE: + raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB") + + for up in files: + # Sanitize filename + original_name = Path(up.filename or "file").name + ext = Path(original_name).suffix.lower() + uid = uuid.uuid4().hex + safe_name = f"{new_message.id}_{uid}{ext or ''}" + out_path = FILES_NORMAL_DIR / safe_name + + content = await up.read() + up.file.seek(0) + + # If image, try lossless optimization + try: + if up.content_type and up.content_type.startswith("image/"): + image = Image.open(io.BytesIO(content)) + img_format = image.format or ("PNG" if ext == ".png" else "JPEG") + buf = io.BytesIO() + save_kwargs = {"optimize": True} + if img_format.upper() == "JPEG": + # Use quality=95 with optimize to keep high quality (not truly lossless but near) + save_kwargs["quality"] = 95 + image.save(buf, format=img_format, **save_kwargs) + buf.seek(0) + content = buf.read() + except Exception: + # Fallback to original content + pass + + with open(out_path, "wb") as f: + f.write(content) + + mf = MessageFile( + message_id=new_message.id, + path=str(out_path), + encrypted=False, + filename=original_name, + content_type=up.content_type, + size=len(content), + ) + db.add(mf) + db.commit() + db.refresh(new_message) + # Send push notifications for public messages try: await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id) except Exception as e: logger.error(f"Failed to send push notification for message {new_message.id}: {e}") + # Realtime broadcast for HTTP uploads as well + try: + from .messaging import messagingManager # self import safe here + await messagingManager.broadcast({ + "type": "newMessage", + "data": convert_message(new_message) + }) + except Exception: + pass + return {"status": "success", "message": convert_message(new_message)} @@ -83,11 +194,30 @@ async def get_messages(db: Session = Depends(get_db)): @router.post("/dm/send") -async def dm_send(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +async def dm_send( + payload: dict | None = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), + # Multipart support + dm_payload: str | None = Form(default=None), + files: list[UploadFile] = File(default=[]), + fileNames: str | None = Form(default=None), # JSON array of filenames corresponding to files +): + import json + if dm_payload and payload is None: + try: + payload = json.loads(dm_payload) + except Exception: + raise HTTPException(status_code=400, detail="Invalid dm_payload JSON") + + if payload is None: + raise HTTPException(status_code=400, detail="Missing payload") + required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"] for key in required: if key not in payload: raise HTTPException(status_code=400, detail=f"Missing {key}") + env = DMEnvelope( sender_id=current_user.id, recipient_id=int(payload["recipientId"]), @@ -100,13 +230,74 @@ async def dm_send(payload: dict, current_user: User = Depends(get_current_user), db.add(env) db.commit() db.refresh(env) - + + # Save encrypted files if any (no processing) + if files: + # Validate total size + total_size = 0 + for up in files: + if hasattr(up, "size") and up.size is not None: + total_size += int(up.size) + else: + data = await up.read() + up.file.seek(0) + total_size += len(data) + if total_size > MAX_TOTAL_SIZE: + raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB") + + names: list[str] = [] + if fileNames: + try: + decoded = json.loads(fileNames) + if isinstance(decoded, list): + names = [str(x) for x in decoded] + except Exception: + names = [] + + for idx, up in enumerate(files): + provided = names[idx] if idx < len(names) else None + # Sanitize provided name to avoid path traversal + if provided and not re.match(r"^[A-Za-z0-9._-]{1,200}$", provided): + provided = None + original_name = provided or Path(up.filename or "file").name + # Save using provided/original name to allow client to reference path directly + safe_name = original_name + out_path = FILES_ENCRYPTED_DIR / safe_name + + content = await up.read() + with open(out_path, "wb") as f: + f.write(content) + + # We do not store linkage to public messages for DMs; paths will be referenced inside encrypted JSON + # Send push notification for DM try: await push_service.send_dm_notification(db, env, current_user) except Exception as e: logger.error(f"Failed to send push notification for DM {env.id}: {e}") - + + # Realtime notify both users for HTTP requests + try: + from .messaging import messagingManager # self import + payload_ws = { + "type": "dmNew", + "data": { + "id": env.id, + "senderId": env.sender_id, + "recipientId": env.recipient_id, + "iv": env.iv_b64, + "ciphertext": env.ciphertext_b64, + "salt": env.salt_b64, + "iv2": env.iv2_b64, + "wrappedMk": env.wrapped_mk_b64, + "timestamp": env.timestamp.isoformat(), + } + } + await messagingManager.send_to_user(env.recipient_id, payload_ws) + await messagingManager.send_to_user(env.sender_id, payload_ws) + except Exception: + pass + return {"status": "ok", "id": env.id} @@ -284,7 +475,7 @@ class MessaggingSocketManager: request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) - response = await send_message(request, current_user, db) + response = await send_message(request, current_user, db, None, []) await self.broadcast({ "type": "newMessage", "data": response["message"] @@ -414,4 +605,25 @@ async def chat_websocket( websocket: WebSocket, db: Session = Depends(get_db) ): - await messagingManager.connect(websocket, db) \ No newline at end of file + await messagingManager.connect(websocket, db) + + +# File serving endpoints +@router.get("/files/normal/{filename}") +async def get_file_normal(filename: str): + if not re.match(r"^[A-Za-z0-9._-]+$", filename): + raise HTTPException(status_code=400, detail="Invalid file name") + path = FILES_NORMAL_DIR / filename + if not path.exists(): + raise HTTPException(status_code=404, detail="File not found") + return FileResponse(str(path)) + + +@router.get("/files/encrypted/{filename}") +async def get_file_encrypted(filename: str): + if not re.match(r"^[A-Za-z0-9._-]+$", filename): + raise HTTPException(status_code=400, detail="Invalid file name") + path = FILES_ENCRYPTED_DIR / filename + if not path.exists(): + raise HTTPException(status_code=404, detail="File not found") + return FileResponse(str(path)) \ No newline at end of file diff --git a/frontend/src/api/dmApi.ts b/frontend/src/api/dmApi.ts index 90a6446..f5a0cf0 100644 --- a/frontend/src/api/dmApi.ts +++ b/frontend/src/api/dmApi.ts @@ -78,4 +78,4 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey }, data: payload }); -} +} \ No newline at end of file diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 5653ca5..90b5b19 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -60,6 +60,7 @@ export interface Message { timestamp: string; profile_picture?: string; reply_to?: Message; + files?: Attachment[]; } /** @@ -230,6 +231,14 @@ export interface WebSocketCredentials { credentials: string; } +export interface Attachment { + path: string; + encrypted: boolean; + filename?: string; + content_type?: string; + size?: number; +} + // ----------- // React types // ----------- diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index d1c65e7..03ff3bd 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -172,28 +172,7 @@ align-items: flex-end; gap: 8px; - .message-profile-pic { - width: 32px; - height: 32px; - flex-shrink: 0; - margin-bottom: 4px; - - img { - width: 100%; - height: 100%; - border-radius: 50%; - object-fit: cover; - transition: transform 0.2s ease, box-shadow 0.2s ease; - - &:hover { - transform: scale(1.1); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - } - } - } - .message-inner { - padding: 0.8rem 1rem; border-radius: 12px; position: relative; word-wrap: break-word; @@ -203,9 +182,43 @@ max-width: 100%; display: inline-block; + .message-profile-pic { + width: 32px; + height: 32px; + flex-shrink: 0; + margin-bottom: 4px; + margin: 8px; + + img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + } + } + + .message-username { + font-weight: 600; + margin-bottom: 0.3rem; + font-size: 0.9rem; + transition: color 0.2s ease; + margin: 8px; + + &:hover { + color: $color-dark-primary; + text-decoration: underline; + } + } + .message-content { word-wrap: break-word; - margin-bottom: 10px; + margin: 10px 10px 0 10px; white-space: pre-wrap; > p:first-child { @@ -222,12 +235,22 @@ margin-bottom: 10px; } + .message-attachments { + padding: 5px 0 0 0; + + .attachment { + a { + text-decoration: none; + } + } + } .message-time { font-size: 0.7rem; color: $color-dark-on-surface-variant; margin-top: 0.3rem; text-align: right; user-select: none; + margin: 4px 8px 8px 8px; } } @@ -252,18 +275,6 @@ } } } - - .message-username { - font-weight: 600; - margin-bottom: 0.3rem; - font-size: 0.9rem; - transition: color 0.2s ease; - - &:hover { - color: $color-dark-primary; - text-decoration: underline; - } - } } .chat-input-wrapper { @@ -309,6 +320,16 @@ } } + .attachments-preview { + align-items: center; + + .attachments-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + } + .chat-input { flex: 1; display: flex; @@ -331,22 +352,29 @@ width: 100%; } - .send-btn { - margin: 10px; - width: 50px; - height: 50px; - border-radius: 50%; - background-color: $color-dark-primary; - color: $color-dark-on-primary; - border: none; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background-color 0.25s ease; + .buttons { align-self: flex-end; - - @include hoverStateLayer($background: $color-dark-primary); + display: flex; + flex-direction: row; + align-items: center; + + .send-btn { + margin: 10px; + width: 50px; + height: 50px; + border-radius: 50%; + background-color: $color-dark-primary; + color: $color-dark-on-primary; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.25s ease; + align-self: flex-end; + + @include hoverStateLayer($background: $color-dark-primary); + } } } } diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index a027937..d4d0787 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -1,11 +1,12 @@ import { useState, useEffect } from "react"; +import { MaterialDialog } from "../core/Dialog"; import { RichTextArea } from "../core/RichTextArea"; import type { Message } from "../../../core/types"; import Quote from "../core/Quote"; import AnimatedHeight from "../core/animations/AnimatedHeight"; interface ChatInputWrapperProps { - onSendMessage: (message: string) => void; + onSendMessage: (message: string, files: File[]) => void; onSaveEdit?: (content: string) => void; replyTo?: Message | null; replyToVisible: boolean; @@ -19,6 +20,9 @@ interface ChatInputWrapperProps { export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVisible, onClearReply, onCloseReply, editingMessage, editVisible = false, onClearEdit, onCloseEdit }: ChatInputWrapperProps) { const [message, setMessage] = useState(""); + const [selectedFiles, setSelectedFiles] = useState([]); + const [attachmentsVisible, setAttachmentsVisible] = useState(false); + const [errorOpen, setErrorOpen] = useState(false); // When entering edit mode, preload the message content useEffect(() => { @@ -29,21 +33,47 @@ export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVi } }, [editingMessage]); + useEffect(() => { + if (selectedFiles.length > 0) { + setAttachmentsVisible(true); + } + }, [selectedFiles]) + const handleSubmit = async (e: React.FormEvent | Event) => { e.preventDefault(); - if (message.trim()) { + const hasText = Boolean(message.trim()); + const hasFiles = selectedFiles.length > 0; + if (hasText || hasFiles) { + const totalSize = selectedFiles.reduce((acc, f) => acc + f.size, 0); + const limit = 4 * 1024 * 1024 * 1024; // 4GB + if (totalSize > limit) { + setErrorOpen(true); + return; + } if (editingMessage && onSaveEdit) { onSaveEdit(message); setMessage(""); if (onClearEdit) onClearEdit(); } else { - onSendMessage(message); + onSendMessage(message, selectedFiles); setMessage(""); + setAttachmentsVisible(false); if (onClearReply) onClearReply(); } } }; + function handleAttachClick() { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.onchange = () => { + const files = Array.from(input.files || []); + setSelectedFiles(files); + }; + input.click(); + } + return (
@@ -71,6 +101,28 @@ export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVi
)} + setSelectedFiles([])}> + {selectedFiles.length > 0 && ( +
+ +
+ {selectedFiles.map((f, i) => ( + setSelectedFiles(prev => prev.filter((_, idx) => idx !== i))} + > + + {f.name} + + ))} +
+ setAttachmentsVisible(false)}> +
+ )} +
setMessage(value)} onEnter={handleSubmit} /> - +
+ + +
+ +
Ошибка
+
Общий размер вложений превышает 4 ГБ.
+ setErrorOpen(false)}>Закрыть +
); } diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index e5835be..cd3d30c 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -77,6 +77,25 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
+ {message.files && message.files.length > 0 && ( + + {message.files.map((file, idx) => { + const isImage = !file.encrypted && (file.content_type?.startsWith("image/") || /\.(png|jpg|jpeg|gif|webp)$/i.test(file.filename || "")); + return ( +
+ {isImage ? ( + {file.filename + ) : ( + + {file.filename || file.path.split("/").pop()} + + )} +
+ ); + })} +
+ )} +
{formatTime(message.timestamp)} {message.is_edited ? " (edited)" : undefined} diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index 40feee4..2cf4ac4 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -175,8 +175,8 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen )} { - panel.handleSendMessage(text, replyTo?.id); + onSendMessage={(text, files) => { + panel.handleSendMessage(text, replyTo?.id, files); setReplyTo(null); }} onSaveEdit={(content) => { diff --git a/frontend/src/ui/panels/DMPanel.ts b/frontend/src/ui/panels/DMPanel.ts index d97d968..a00e84f 100644 --- a/frontend/src/ui/panels/DMPanel.ts +++ b/frontend/src/ui/panels/DMPanel.ts @@ -2,7 +2,8 @@ import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from import { fetchDMHistory, decryptDm, - sendDMViaWebSocket + sendDMViaWebSocket, + sendDmWithFiles } from "../../api/dmApi"; import type { Message, WebSocketMessage } from "../../core/types"; import type { UserState } from "../state"; @@ -88,16 +89,27 @@ export class DMPanel extends MessagePanel { } } - async sendMessage(content: string, _replyToId?: number): Promise { + async sendMessage(content: string, _replyToId?: number, files: File[] = []): Promise { if (!this.currentUser.authToken || !this.dmData || !content.trim()) return; try { - await sendDMViaWebSocket( - this.dmData.userId, - this.dmData.publicKey, - content, - this.currentUser.authToken - ); + if (files.length === 0) { + await sendDMViaWebSocket( + this.dmData.userId, + this.dmData.publicKey, + content, + this.currentUser.authToken + ); + } else { + const json = JSON.stringify({ type: "text", data: { content: content.trim() } }); + await sendDmWithFiles( + this.dmData.userId, + this.dmData.publicKey, + json, + files, + this.currentUser.authToken + ); + } } catch (error) { console.error("Failed to send DM:", error); } diff --git a/frontend/src/ui/panels/MessagePanel.ts b/frontend/src/ui/panels/MessagePanel.ts index da704c3..960b437 100644 --- a/frontend/src/ui/panels/MessagePanel.ts +++ b/frontend/src/ui/panels/MessagePanel.ts @@ -12,7 +12,7 @@ export interface MessagePanelState { } export interface MessagePanelCallbacks { - onSendMessage: (content: string) => void; + onSendMessage: (content: string, files: File[]) => void; onEditMessage: (messageId: number, content: string) => void; onDeleteMessage: (messageId: number) => void; onReplyToMessage: (messageId: number, content: string) => void; @@ -48,7 +48,7 @@ export abstract class MessagePanel { abstract activate(): Promise; abstract deactivate(): void; abstract loadMessages(): Promise; - abstract sendMessage(content: string, replyToId?: number): Promise; + abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise; abstract isDm(): boolean; // Optional WebSocket message handler (can be overridden by subclasses) @@ -115,8 +115,8 @@ export abstract class MessagePanel { } // Event handlers - handleSendMessage = (content: string, replyToId?: number): void => { - this.sendMessage(content, replyToId); + handleSendMessage = (content: string, replyToId?: number, files: File[] = []): void => { + this.sendMessage(content, replyToId, files); }; handleEditMessage = (messageId: number, content: string): void => { diff --git a/frontend/src/ui/panels/PublicChatPanel.ts b/frontend/src/ui/panels/PublicChatPanel.ts index dd5f2f7..fe997ac 100644 --- a/frontend/src/ui/panels/PublicChatPanel.ts +++ b/frontend/src/ui/panels/PublicChatPanel.ts @@ -61,24 +61,37 @@ export class PublicChatPanel extends MessagePanel { } } - async sendMessage(content: string, replyToId?: number): Promise { + async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise { if (!this.currentUser.authToken || !content.trim()) return; try { - const response = await request({ - data: { - content: content.trim(), - reply_to_id: replyToId ?? null - }, - credentials: { - scheme: "Bearer", - credentials: this.currentUser.authToken - }, - type: "sendMessage" - }); - - if (response.error) { - console.error("Error sending message:", response.error); + if (files.length === 0) { + const response = await request({ + data: { + content: content.trim(), + reply_to_id: replyToId ?? null + }, + credentials: { + scheme: "Bearer", + credentials: this.currentUser.authToken + }, + type: "sendMessage" + }); + if (response.error) { + console.error("Error sending message:", response.error); + } + } else { + const form = new FormData(); + form.append("payload", JSON.stringify({ type: "text", data: { content: content.trim() }, reply_to_id: replyToId ?? null })); + for (const f of files) form.append("files", f, f.name); + const res = await fetch(`${API_BASE_URL}/send_message`, { + method: "POST", + headers: getAuthHeaders(this.currentUser.authToken, false), + body: form + }); + if (!res.ok) { + console.error("Error sending message with files", await res.text()); + } } } catch (error) { console.error("Error sending message:", error); diff --git a/frontend/src/utils/material.ts b/frontend/src/utils/material.ts index 34574a5..a5cdb5e 100644 --- a/frontend/src/utils/material.ts +++ b/frontend/src/utils/material.ts @@ -20,6 +20,7 @@ import 'mdui/components/button-icon'; import 'mdui/components/top-app-bar'; import 'mdui/components/top-app-bar-title'; import 'mdui/components/switch'; +import 'mdui/components/chip'; import { setColorScheme } from 'mdui/functions/setColorScheme.js';