From 6066ec9767e683a24ddf3d39f2f33955f0b410e4 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 24 Sep 2025 14:14:59 +0300 Subject: [PATCH 1/8] 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'; From 1db2d55f76cfe307a02cb193fad5905fdf67a5cd Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 24 Sep 2025 17:12:02 +0300 Subject: [PATCH 2/8] Fix files, delete, edit in DMs --- backend/models.py | 21 ++- backend/routes/messaging.py | 169 +++++++++++++----- frontend/src/api/dmApi.ts | 96 +++++++++- frontend/src/core/types.d.ts | 70 +++++++- frontend/src/resources/css/_chat.scss | 2 +- .../ui/components/chat/ChatInputWrapper.tsx | 15 +- .../src/ui/components/chat/ChatMessages.tsx | 50 +++--- frontend/src/ui/components/chat/DMPanel.tsx | 128 ------------- .../src/ui/components/chat/DMUsersList.tsx | 5 +- frontend/src/ui/components/chat/Message.tsx | 88 ++++++++- .../components/chat/MessagePanelRenderer.tsx | 2 + frontend/src/ui/hooks/useDM.ts | 50 +----- frontend/src/ui/panels/DMPanel.ts | 166 +++++++++++++---- frontend/src/ui/panels/MessagePanel.ts | 30 +--- frontend/src/ui/panels/PublicChatPanel.ts | 40 ++++- frontend/src/ui/state.ts | 46 +---- 16 files changed, 601 insertions(+), 377 deletions(-) delete mode 100644 frontend/src/ui/components/chat/DMPanel.tsx diff --git a/backend/models.py b/backend/models.py index 13bec2f..c50210e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, text +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text from sqlalchemy.orm import relationship from datetime import datetime from db import engine @@ -45,10 +45,6 @@ class MessageFile(Base): 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") @@ -80,7 +76,22 @@ class DMEnvelope(Base): salt_b64 = Column(Text, nullable=False) iv2_b64 = Column(Text, nullable=False) wrapped_mk_b64 = Column(Text, nullable=False) + reply_to_id = Column(Integer, nullable=True) timestamp = Column(DateTime, default=datetime.now) + files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select") + + +class DMFile(Base): + __tablename__ = "dm_file" + + id = Column(Integer, primary_key=True, index=True) + message_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True) + sender_id = Column(Integer, ForeignKey("user.id"), nullable=False) + recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False) + name = Column(Text, nullable=False) + path = Column(Text, nullable=False) + + message = relationship("DMEnvelope", back_populates="files") class PushSubscription(Base): diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 81b83f6..e17e0eb 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -4,14 +4,13 @@ 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, MessageFile +from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile from push_service import push_service from PIL import Image import io @@ -150,11 +149,7 @@ async def send_message( mf = MessageFile( message_id=new_message.id, - path=str(out_path), - encrypted=False, - filename=original_name, - content_type=up.content_type, - size=len(content), + path=str(out_path) ) db.add(mf) db.commit() @@ -203,7 +198,6 @@ async def dm_send( 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) @@ -226,6 +220,7 @@ async def dm_send( salt_b64=payload["salt"], iv2_b64=payload["iv2"], wrapped_mk_b64=payload["wrappedMk"], + reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None, ) db.add(env) db.commit() @@ -235,12 +230,12 @@ async def dm_send( 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) + for file in files: + if hasattr(file, "size") and file.size is not None: + total_size += int(file.size) else: - data = await up.read() - up.file.seek(0) + data = await file.read() + file.file.seek(0) total_size += len(data) if total_size > MAX_TOTAL_SIZE: raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB") @@ -254,21 +249,31 @@ async def dm_send( except Exception: names = [] - for idx, up in enumerate(files): - provided = names[idx] if idx < len(names) else None + for i, file in enumerate(files): + provided = names[i] if i < 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 + original_name = provided or Path(file.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 + out_name = f"{current_user.id}_{env.recipient_id}_{env.id}_{safe_name}" + out_path = FILES_ENCRYPTED_DIR / out_name - content = await up.read() + content = await file.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 + # Save DM file record + df = DMFile( + message_id=env.id, + sender_id=current_user.id, + recipient_id=env.recipient_id, + path=f"/api/uploads/files/encrypted/{out_name}", + name=safe_name + ) + db.add(df) + db.commit() # Send push notification for DM try: @@ -278,7 +283,6 @@ async def dm_send( # Realtime notify both users for HTTP requests try: - from .messaging import messagingManager # self import payload_ws = { "type": "dmNew", "data": { @@ -291,6 +295,7 @@ async def dm_send( "iv2": env.iv2_b64, "wrappedMk": env.wrapped_mk_b64, "timestamp": env.timestamp.isoformat(), + "replyToId": env.reply_to_id, } } await messagingManager.send_to_user(env.recipient_id, payload_ws) @@ -300,13 +305,7 @@ async def dm_send( return {"status": "ok", "id": env.id} - -@router.get("/dm/fetch") -async def dm_fetch(since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): - q = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id) - if since: - q = q.filter(DMEnvelope.id > since) - envs = q.order_by(DMEnvelope.id.asc()).all() +def convert_envelopes(envs: list[DMEnvelope]): return { "status": "ok", "messages": [ @@ -320,15 +319,23 @@ async def dm_fetch(since: int | None = None, current_user: User = Depends(get_cu "iv2": e.iv2_b64, "wrappedMk": e.wrapped_mk_b64, "timestamp": e.timestamp.isoformat(), + "files": [{"name": file.name, "path": file.path, "id": file.id} for file in e.files] } for e in envs ] } +@router.get("/dm/fetch") +async def dm_fetch(since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + q = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id) + if since: + q = q.filter(DMEnvelope.id > since) + return convert_envelopes(q.order_by(DMEnvelope.id.asc()).all()) + @router.get("/dm/history/{other_user_id}") async def dm_history(other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): - envs = ( + return convert_envelopes( db.query(DMEnvelope) .filter( ((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id)) @@ -337,23 +344,6 @@ async def dm_history(other_user_id: int, current_user: User = Depends(get_curren .order_by(DMEnvelope.id.asc()) .all() ) - return { - "status": "ok", - "messages": [ - { - "id": e.id, - "senderId": e.sender_id, - "recipientId": e.recipient_id, - "iv": e.iv_b64, - "ciphertext": e.ciphertext_b64, - "salt": e.salt_b64, - "iv2": e.iv2_b64, - "wrappedMk": e.wrapped_mk_b64, - "timestamp": e.timestamp.isoformat(), - } - for e in envs - ] - } @router.put("/edit_message/{message_id}") @@ -503,6 +493,7 @@ class MessaggingSocketManager: salt_b64=payload["salt"], iv2_b64=payload["iv2"], wrapped_mk_b64=payload["wrappedMk"], + reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None, ) db.add(env) db.commit() @@ -520,6 +511,7 @@ class MessaggingSocketManager: "iv2": env.iv2_b64, "wrappedMk": env.wrapped_mk_b64, "timestamp": env.timestamp.isoformat(), + "replyToId": env.reply_to_id, } } @@ -552,6 +544,76 @@ class MessaggingSocketManager: await websocket.send_json({"type": type, "data": response}) except HTTPException as e: await self.send_error(websocket, type, e) + elif type == "dmEdit": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + payload = data["data"] + env_id = int(payload["id"]) + env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first() + if not env: + raise HTTPException(status_code=404, detail="DM not found") + if env.sender_id != current_user.id: + raise HTTPException(status_code=403, detail="You can only edit your own messages") + + # Replace ciphertext and iv + env.iv_b64 = payload["iv"] + env.ciphertext_b64 = payload["ciphertext"] + env.iv2_b64 = payload["iv2"] + env.wrapped_mk_b64 = payload["wrappedMk"] + env.salt_b64 = payload["salt"] + db.commit() + db.refresh(env) + + payload_ws = { + "type": "dmEdited", + "data": { + "id": env.id, + "iv": env.iv_b64, + "ciphertext": env.ciphertext_b64, + "iv2": env.iv2_b64, + "wrappedMk": env.wrapped_mk_b64, + "salt": env.salt_b64, + "timestamp": env.timestamp.isoformat(), + } + } + await self.send_to_user(env.recipient_id, payload_ws) + await self.send_to_user(env.sender_id, payload_ws) + await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "dmDelete": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + payload = data["data"] + env_id = int(payload["id"]) + env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first() + if not env: + raise HTTPException(status_code=404, detail="DM not found") + if env.sender_id != current_user.id: + raise HTTPException(status_code=403, detail="You can only delete your own messages") + + db.delete(env) + db.commit() + + payload_ws = { + "type": "dmDeleted", + "data": { + "id": env_id, + "senderId": current_user.id, + "recipientId": payload.get("recipientId") + } + } + await self.send_to_user(env.recipient_id, payload_ws) + await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}}) + await self.send_to_user(env.sender_id, payload_ws) + except HTTPException as e: + await self.send_error(websocket, type, e) elif type == "deleteMessage": try: current_user = get_current_user_inner() @@ -609,7 +671,7 @@ async def chat_websocket( # File serving endpoints -@router.get("/files/normal/{filename}") +@router.get("/uploads/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") @@ -619,11 +681,22 @@ async def get_file_normal(filename: str): return FileResponse(str(path)) -@router.get("/files/encrypted/{filename}") -async def get_file_encrypted(filename: str): +@router.get("/uploads/files/encrypted/{filename}") +async def get_file_encrypted(filename: str, current_user: User = Depends(get_current_user)): 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") + + match = re.match(r"^(\d+)_(\d+)_(\d+)_.*$", path.resolve().name) + if match: + sender_id = int(match.group(1)) + recipient_id = int(match.group(2)) + + if not current_user.id in [sender_id, recipient_id]: + raise HTTPException(403) + else: + raise HTTPException(500) + 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 f5a0cf0..86194a9 100644 --- a/frontend/src/api/dmApi.ts +++ b/frontend/src/api/dmApi.ts @@ -5,7 +5,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/s import { randomBytes } from "../utils/crypto/kdf"; import { getCurrentKeys } from "../auth/crypto"; import { request } from "../core/websocket"; -import type { SendDMRequest, DmEnvelope, User } from "../core/types"; +import type { SendDMRequest, DmEnvelope, User, DMEditWebSocketMessage, DmEncryptedJSON, BaseDmEnvelope } from "../core/types"; import { b64, ub64 } from "../utils/utils"; export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { @@ -46,7 +46,7 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe return data.messages || []; } -export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string): Promise { +export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise { const keys = getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); @@ -69,6 +69,7 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey iv2: b64(wrap.iv), wrappedMk: b64(wrap.ciphertext) }; + if (replyToId) payload.replyToId = replyToId; await request({ type: "dmSend", @@ -78,4 +79,93 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey }, data: payload }); -} \ No newline at end of file +} + +export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + const wrap = await aesGcmEncrypt(wk, mk); + + const form = new FormData(); + const names: string[] = []; + function sliceBuffer(u8: Uint8Array): ArrayBuffer { + return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength); + } + + for (const f of files) { + // Encrypt file with same mk + const data = new Uint8Array(await f.arrayBuffer()); + const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data); + const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" }); + const serverName = f.name; // server uses provided name + names.push(serverName); + form.append("files", new File([blob], serverName)); + } + form.append("fileNames", JSON.stringify(names)); + + // Merge files metadata into plaintext JSON and encrypt + let obj: DmEncryptedJSON; + try { + obj = JSON.parse(plaintextJson); + } catch { + obj = { type: "text", data: { content: String(plaintextJson) } }; + } + + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj))); + form.append("dm_payload", JSON.stringify({ + recipientId: recipientId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + } satisfies BaseDmEnvelope)); + + await fetch(`${API_BASE_URL}/dm/send`, { + method: "POST", + headers: getAuthHeaders(token, false), + body: form + }); +} + +export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson)); + const wrap = await aesGcmEncrypt(wk, mk); + + await request({ + type: "dmEdit", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { + id, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext), + salt: b64(wkSalt) + } + } as DMEditWebSocketMessage); +} + +export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise { + await request({ + type: "dmDelete", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { id, recipientId } + }); +} diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 90b5b19..7d8d852 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -155,6 +155,7 @@ export interface SendDMRequest { salt: string; iv2: string; wrappedMk: string; + replyToId?: number; } // Responses @@ -174,22 +175,54 @@ export interface BackupBlob { blob: string; } -export interface DmEnvelope { - id: number; - senderId: number; - recipientId: number; +export interface BaseDmEnvelope { iv: string; ciphertext: string; salt: string; iv2: string; wrappedMk: string; + recipientId: number; +} + +export interface DmEnvelope extends BaseDmEnvelope { + id: number; + senderId: number; + files?: DmFile[]; timestamp: string; } +export interface DmFile { + name: string; + id: number; + path: string; +} + +export interface DmEditedPayload { + id: number; + iv: string; + ciphertext: string; + timestamp: string +} + +export interface DmDeletedPayload { + id: number; + senderId: number; + recipientId: number +} + export interface FetchDMResponse { messages: DmEnvelope[] } +export interface DmEncryptedJSON { + type: "text", + data: { + content: string; + reply_to_id?: number; + files?: Attachment[]; + } +} + // --------------- // WebSocket types // --------------- @@ -231,6 +264,18 @@ export interface WebSocketCredentials { credentials: string; } +export interface DMEditWebSocketMessage extends WebSocketMessage { + type: "dmEdit", + data: { + id: number; + iv: string; + ciphertext: string; + iv2: string; + wrappedMk: string; + salt: string; + } +} + export interface Attachment { path: string; encrypted: boolean; @@ -239,6 +284,23 @@ export interface Attachment { size?: number; } +// ----------- +// Encrypted message JSON (plaintext structure before encryption) +// ----------- + +export type ChatMessageKind = "text"; // Extendable for future kinds + +export interface EncryptedTextMessageData { + content: string; + files?: Attachment[]; + reply_to_id?: number | null; +} + +export interface EncryptedMessageJson { + type: ChatMessageKind; + data: EncryptedTextMessageData; +} + // ----------- // React types // ----------- diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index 03ff3bd..6d5087b 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -232,7 +232,7 @@ .quote.reply-preview { user-select: none; - margin-bottom: 10px; + margin: 10px; } .message-attachments { diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index d4d0787..e7b419d 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -18,7 +18,20 @@ interface ChatInputWrapperProps { onCloseEdit?: () => void; } -export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVisible, onClearReply, onCloseReply, editingMessage, editVisible = false, onClearEdit, onCloseEdit }: 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); diff --git a/frontend/src/ui/components/chat/ChatMessages.tsx b/frontend/src/ui/components/chat/ChatMessages.tsx index c835004..e1e3799 100644 --- a/frontend/src/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/ui/components/chat/ChatMessages.tsx @@ -8,7 +8,6 @@ import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu" import { fetchUserProfile } from "../../../api/profileApi"; import { useEffect, useState, type ReactNode } from "react"; import { delay } from "../../../utils/utils"; -import { request } from "../../../core/websocket"; import { MaterialDialog } from "../core/Dialog"; interface ChatMessagesProps { @@ -17,9 +16,11 @@ interface ChatMessagesProps { children?: ReactNode; onReplySelect?: (message: MessageType) => void; onEditSelect?: (message: MessageType) => void; + onDelete?: (id: number) => void; + dmRecipientPublicKey?: string; } -export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect }: ChatMessagesProps) { +export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect, onDelete, dmRecipientPublicKey }: ChatMessagesProps) { const { messages: hookMessages } = useChat(); const { user } = useAppState(); @@ -38,7 +39,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o // Delete dialog const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [toBeDeleted, setToBeDeleted] = useState(null); + const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null); useEffect(() => { if (!deleteDialogOpen) { @@ -88,28 +89,31 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o }; async function confirmDelete() { - if (toBeDeleted) { - if (!user.authToken) return; - - try { - await request({ - type: "deleteMessage", - data: { message_id: toBeDeleted }, - credentials: { - scheme: "Bearer", - credentials: user.authToken - } - }); - } catch (error) { - console.error("Failed to delete message:", error); - } - - setDeleteDialogOpen(false); + if (!toBeDeleted || !user.authToken) return; + try { + onDelete?.(toBeDeleted.id); + // if (toBeDeleted.isDm) { + // // For DM, send dmDelete + // await request({ + // type: "dmDelete", + // data: { id: toBeDeleted.id }, + // credentials: { scheme: "Bearer", credentials: user.authToken } + // }); + // } else { + // await request({ + // type: "deleteMessage", + // data: { message_id: toBeDeleted.id }, + // credentials: { scheme: "Bearer", credentials: user.authToken } + // }); + // } + } catch (error) { + console.error("Failed to delete message:", error); } + setDeleteDialogOpen(false); } async function handleDelete(message: MessageType) { - setToBeDeleted(message.id); + setToBeDeleted({ id: message.id, isDm }); setDeleteDialogOpen(true); } @@ -124,7 +128,9 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o onProfileClick={handleProfileClick} onContextMenu={handleContextMenu} isLoadingProfile={isLoadingProfile} - isDm={isDm} /> + isDm={isDm} + dmRecipientPublicKey={dmRecipientPublicKey} + dmEnvelope={(message as any).dmEnvelope} /> ))} {children}
diff --git a/frontend/src/ui/components/chat/DMPanel.tsx b/frontend/src/ui/components/chat/DMPanel.tsx deleted file mode 100644 index bb055b3..0000000 --- a/frontend/src/ui/components/chat/DMPanel.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useState, useEffect, useRef } from "react"; -import { useAppState } from "../../state"; -import { useDM } from "../../hooks/useDM"; -import { ChatMessages } from "./ChatMessages"; -import defaultAvatar from "../../../resources/images/default-avatar.png"; - -export function DMPanel() { - const { chat } = useAppState(); - const { sendDMMessage, isLoadingHistory } = useDM(); - const [message, setMessage] = useState(""); - const messagesEndRef = useRef(null); - - const activeDm = chat.activeDm; - - // Scroll to bottom when messages change - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [chat.messages]); - - const handleSendMessage = async (e: React.FormEvent) => { - e.preventDefault(); - if (!message.trim() || !activeDm?.publicKey) return; - - try { - await sendDMMessage(activeDm.userId, activeDm.publicKey, message); - setMessage(""); - } catch (error) { - console.error("Failed to send DM:", error); - } - }; - - const handleProfileClick = () => { - // TODO: Implement profile dialog for DM user - console.log("Profile clicked for DM user:", activeDm?.username); - }; - - if (!activeDm) { - return ( -
-
- Avatar -
-
-

Выберите пользователя

-

- - Выберите пользователя для начала разговора -

-
-
-
-
-
- Выберите пользователя из списка для начала личных сообщений -
-
-
- ); - } - - return ( -
-
- Avatar -
-
-

{activeDm.username}

-

- - Личные сообщения -

-
- Свернуть чат -
-
- -
- {isLoadingHistory ? ( -
- Загрузка сообщений... -
- ) : ( - <> - -
- - )} -
- -
-
-
- setMessage(e.target.value)} - /> - -
-
-
-
- ); -} diff --git a/frontend/src/ui/components/chat/DMUsersList.tsx b/frontend/src/ui/components/chat/DMUsersList.tsx index df01f1c..cd2b1a5 100644 --- a/frontend/src/ui/components/chat/DMUsersList.tsx +++ b/frontend/src/ui/components/chat/DMUsersList.tsx @@ -38,10 +38,7 @@ export function DMUsersList() { if (!user.publicKey) { // Get public key if not already loaded const authToken = useAppState.getState().user.authToken; - if (!authToken) { - console.error("No auth token available"); - return; - } + if (!authToken) return; const publicKey = await fetchUserPublicKey(user.id, authToken); if (publicKey) { diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index cd3d30c..5f6d56b 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -5,6 +5,12 @@ import Quote from "../core/Quote"; import { parse } from "marked"; import DOMPurify from "dompurify"; import { useEffect, useState } from "react"; +import { getCurrentKeys } from "../../../auth/crypto"; +import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric"; +import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric"; +import { getAuthHeaders } from "../../../auth/api"; +import { useAppState } from "../../state"; +import { ub64 } from "../../../utils/utils"; interface MessageProps { message: MessageType; @@ -13,10 +19,18 @@ interface MessageProps { onContextMenu: (e: React.MouseEvent, message: MessageType) => void; isLoadingProfile?: boolean; isDm?: boolean; + dmRecipientPublicKey?: string; + dmEnvelope?: { + salt: string; + iv2: string; + wrappedMk: string; + }; } -export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false }: MessageProps) { - const [formattedMessage, setFormattedMessage] = useState({ __html: DOMPurify.sanitize(message.content).trim() }); +export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey, dmEnvelope }: MessageProps) { + const [formattedMessage, setFormattedMessage] = useState({ __html: "" }); + const [decryptedFiles, setDecryptedFiles] = useState>(new Map()); + const { user } = useAppState(); useEffect(() => { (async () => { @@ -28,6 +42,54 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo })(); }, [message]); + const decryptFile = async (file: any): Promise => { + if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null; + + // Check if already decrypted + if (decryptedFiles.has(file.path)) { + return decryptedFiles.get(file.path) || null; + } + + try { + // Fetch encrypted file + const response = await fetch(file.path, { + headers: getAuthHeaders(user.authToken!) + }); + if (!response.ok) throw new Error("Failed to fetch file"); + + const encryptedData = await response.arrayBuffer(); + + // Get current user's keys + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // Derive shared secret with the recipient's public key + const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey)); + + // Derive wrapping key using the salt from the DM envelope + const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + // Unwrap the message key + const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk)); + + // Decrypt the file using the message key + const iv = new Uint8Array(encryptedData, 0, 12); + const ciphertext = new Uint8Array(encryptedData, 12); + const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext); + + // Create blob URL for download + const blob = new Blob([decrypted.buffer as ArrayBuffer]); + const url = URL.createObjectURL(blob); + + setDecryptedFiles(prev => new Map(prev).set(file.path, url)); + return url; + } catch (error) { + console.error("Failed to decrypt file:", error); + return null; + } + }; + function handleContextMenu(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); @@ -81,13 +143,31 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo {message.files.map((file, idx) => { const isImage = !file.encrypted && (file.content_type?.startsWith("image/") || /\.(png|jpg|jpeg|gif|webp)$/i.test(file.filename || "")); + const downloadUrl = decryptedFiles.get(file.path) || file.path; return ( diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index 2cf4ac4..76939f0 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -153,6 +153,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen { if (editMessage || editVisible) { setPendingAction({ type: "reply", message: message }); @@ -169,6 +170,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen setEditMessage(message); } }} + onDelete={(id) => panel.handleDeleteMessage(id)} >
diff --git a/frontend/src/ui/hooks/useDM.ts b/frontend/src/ui/hooks/useDM.ts index af7f5d7..8b7cc8b 100644 --- a/frontend/src/ui/hooks/useDM.ts +++ b/frontend/src/ui/hooks/useDM.ts @@ -7,7 +7,7 @@ import { decryptDm, sendDMViaWebSocket } from "../../api/dmApi"; -import type { User, Message } from "../../core/types"; +import type { User, Message, DmEncryptedJSON } from "../../core/types"; import { websocket } from "../../core/websocket"; interface DMUser extends User { @@ -41,7 +41,8 @@ export function useDM() { let lastPlaintext: string | null = null; try { - lastPlaintext = await decryptDm(lastMessage, publicKey); + lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content; + console.log(lastPlaintext); } catch (error) { console.error("Failed to decrypt last message:", error); } @@ -93,50 +94,7 @@ export function useDM() { // Load last messages and unread counts for visible users // Call loadUserLastMessage directly without dependency for (const dmUser of dmUsersWithState) { - if (!user.authToken) continue; - - try { - // Get public key - const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken); - if (!publicKey) continue; - - // Get message history - const messages = await fetchDMHistory(dmUser.id, user.authToken, 50); - if (messages.length === 0) continue; - - // Find last message - const lastMessage = messages[messages.length - 1]; - let lastPlaintext: string | null = null; - - try { - lastPlaintext = await decryptDm(lastMessage, publicKey); - } catch (error) { - console.error("Failed to decrypt last message:", error); - } - - // Calculate unread count - const lastReadId = getLastReadId(dmUser.id); - let unreadCount = 0; - for (const msg of messages) { - if (msg.senderId === dmUser.id && msg.id > lastReadId) { - unreadCount++; - } - } - - // Update user state - setDmUsersState(prev => prev.map(u => - u.id === dmUser.id - ? { - ...u, - lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined, - unreadCount, - publicKey - } - : u - )); - } catch (error) { - console.error("Failed to load last message for user:", dmUser.id, error); - } + await loadUserLastMessage(dmUser); } } catch (error) { console.error("Failed to load DM users:", error); diff --git a/frontend/src/ui/panels/DMPanel.ts b/frontend/src/ui/panels/DMPanel.ts index a00e84f..14af6de 100644 --- a/frontend/src/ui/panels/DMPanel.ts +++ b/frontend/src/ui/panels/DMPanel.ts @@ -1,11 +1,13 @@ -import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from "./MessagePanel"; +import { MessagePanel } from "./MessagePanel"; import { fetchDMHistory, decryptDm, sendDMViaWebSocket, - sendDmWithFiles + sendDmWithFiles, + editDmEnvelope, + deleteDmEnvelope } from "../../api/dmApi"; -import type { Message, WebSocketMessage } from "../../core/types"; +import type { DmEncryptedJSON, DmEnvelope, EncryptedMessageJson, Message, WebSocketMessage } from "../../core/types"; import type { UserState } from "../state"; export interface DMPanelData { @@ -21,11 +23,9 @@ export class DMPanel extends MessagePanel { private messagesLoaded: boolean = false; constructor( - user: UserState, - callbacks: MessagePanelCallbacks, - onStateChange: (state: MessagePanelState) => void + user: UserState ) { - super("dm", user, callbacks, onStateChange); + super("dm", user); } isDm(): boolean { @@ -42,6 +42,45 @@ export class DMPanel extends MessagePanel { // DM doesn't need special cleanup } + private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) { + const plaintext = await decryptDm(env, this.dmData!.publicKey); + const isAuthor = env.senderId !== this.dmData!.userId; + const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username; + + // Try parse JSON payload { type: "text", data: { content, files?, reply_to_id? } } + let content = plaintext; + let reply_to_id: number | undefined = undefined; + try { + const obj = JSON.parse(plaintext) as DmEncryptedJSON; + if (obj && obj.type === "text" && obj.data) { + content = obj.data.content; + reply_to_id = Number(obj.data.reply_to_id) || undefined; + } + } catch {} + + const dmMsg: Message & { dmEnvelope?: { salt: string; iv2: string; wrappedMk: string } } = { + id: env.id, + content: content, + username: username, + timestamp: env.timestamp, + is_read: false, + is_edited: false, + files: env.files?.map(file => { return {"filename": file.name, "encrypted": true, "path": file.path} }) || [], + dmEnvelope: { + salt: env.salt, + iv2: env.iv2, + wrappedMk: env.wrappedMk + } + }; + + if (reply_to_id) { + const referenced = decryptedMessages.find(m => m.id === reply_to_id); + if (referenced) dmMsg.reply_to = referenced; + } + + return dmMsg; + } + async loadMessages(): Promise { if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return; @@ -53,18 +92,8 @@ export class DMPanel extends MessagePanel { for (const env of messages) { try { - const text = await decryptDm(env, this.dmData!.publicKey); - const isAuthor = env.senderId !== this.dmData!.userId; - const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username; - - decryptedMessages.push({ - id: env.id, - content: text, - username: username, - timestamp: env.timestamp, - is_read: false, - is_edited: false - }); + const dmMsg = await this.parseTextPayload(env, decryptedMessages); + decryptedMessages.push(dmMsg); if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) { maxIncomingId = env.id; @@ -89,19 +118,27 @@ export class DMPanel extends MessagePanel { } } - async sendMessage(content: string, _replyToId?: number, files: File[] = []): Promise { + async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise { if (!this.currentUser.authToken || !this.dmData || !content.trim()) return; try { + const payload: DmEncryptedJSON = { + type: "text", + data: { + content: content.trim(), + reply_to_id: replyToId ?? undefined + } + } + const json = JSON.stringify(payload); + if (files.length === 0) { await sendDMViaWebSocket( - this.dmData.userId, - this.dmData.publicKey, - content, + this.dmData.userId, + this.dmData.publicKey, + json, this.currentUser.authToken ); } else { - const json = JSON.stringify({ type: "text", data: { content: content.trim() } }); await sendDmWithFiles( this.dmData.userId, this.dmData.publicKey, @@ -130,25 +167,16 @@ export class DMPanel extends MessagePanel { // Handle incoming WebSocket DM messages handleWebSocketMessage = async (response: WebSocketMessage): Promise => { if (response.type === "dmNew" && this.dmData) { - const { senderId, recipientId, ...envelope } = response.data; + const envelope = response.data as DmEnvelope; // If this is for the active DM conversation - if (senderId === this.dmData.userId || recipientId === this.dmData.userId) { + if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) { try { - const plaintext = await decryptDm(envelope, this.dmData.publicKey); - const isAuthor = senderId !== this.dmData.userId; - - this.addMessage({ - id: envelope.id, - content: plaintext, - username: isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData.username, - timestamp: envelope.timestamp, - is_read: false, - is_edited: false - }); + const dmMsg = await this.parseTextPayload(envelope, this.getMessages()); + this.addMessage(dmMsg); // Update last read if it's from the other user - if (senderId === this.dmData.userId) { + if (envelope.senderId === this.dmData.userId) { this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id)); } } catch (error) { @@ -156,6 +184,43 @@ export class DMPanel extends MessagePanel { } } } + if (response.type === "dmEdited" && this.dmData) { + const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data; + try { + // Decrypt new content in-place + const plaintext = await decryptDm( + { + id, + senderId: 0, + recipientId: 0, + iv, + ciphertext, + salt, + iv2, + wrappedMk, + timestamp: new Date().toISOString() + }, + this.dmData.publicKey + ); + let content = plaintext; + let files: Message["files"] | undefined = undefined; + try { + const obj = JSON.parse(plaintext) as EncryptedMessageJson; + if (obj.type === "text" && obj.data) { + content = obj.data.content; + files = obj.data.files; + } + } catch {} + const updates: Partial = { content, is_edited: true, files }; + this.updateMessage(id, updates); + } catch (e) { + this.updateMessage(id, { is_edited: true }); + } + } + if (response.type === "dmDeleted" && this.dmData) { + const { id } = response.data; + this.removeMessage(id); + } }; // Reset for DM switching @@ -191,4 +256,29 @@ export class DMPanel extends MessagePanel { localStorage.setItem(`dmLastRead:${userId}`, String(id)); } catch {} } + + async handleDeleteMessage(messageId: number): Promise { + if (!this.currentUser.authToken || !this.dmData) return; + // Fire and forget; UI will update via dmDeleted + await deleteDmEnvelope(messageId, this.dmData.userId, this.currentUser.authToken); + } + + async handleEditMessage(messageId: number, content: string): Promise { + if (!this.currentUser.authToken || !this.dmData) return; + const msg = this.getMessages().find(m => m.id === messageId); + // Build encrypted JSON preserving files and reply_to if present + const payload: EncryptedMessageJson = { + type: "text", + data: { + content: content, + files: msg?.files, + reply_to_id: msg?.reply_to?.id ?? undefined + } + }; + editDmEnvelope(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => { + console.error("Failed to edit DM:", e); + }); + } + + handleProfileClick(): void {} } diff --git a/frontend/src/ui/panels/MessagePanel.ts b/frontend/src/ui/panels/MessagePanel.ts index 960b437..b1e74b0 100644 --- a/frontend/src/ui/panels/MessagePanel.ts +++ b/frontend/src/ui/panels/MessagePanel.ts @@ -21,15 +21,12 @@ export interface MessagePanelCallbacks { export abstract class MessagePanel { protected state: MessagePanelState; - protected callbacks: MessagePanelCallbacks; - public onStateChange: ((state: MessagePanelState) => void) | null; - protected currentUser: UserState; + public onStateChange: ((state: MessagePanelState) => void) | null = () => {}; + protected readonly currentUser: UserState; constructor( id: string, currentUser: UserState, - callbacks: MessagePanelCallbacks, - onStateChange: (state: MessagePanelState) => void ) { this.state = { id, @@ -40,8 +37,6 @@ export abstract class MessagePanel { isTyping: false }; this.currentUser = currentUser; - this.callbacks = callbacks; - this.onStateChange = onStateChange; } // Abstract methods that must be implemented by subclasses @@ -115,23 +110,10 @@ export abstract class MessagePanel { } // Event handlers - handleSendMessage = (content: string, replyToId?: number, files: File[] = []): void => { + handleSendMessage(content: string, replyToId?: number, files: File[] = []): void { this.sendMessage(content, replyToId, files); }; - - handleEditMessage = (messageId: number, content: string): void => { - this.callbacks.onEditMessage(messageId, content); - }; - - handleDeleteMessage = (messageId: number): void => { - this.callbacks.onDeleteMessage(messageId); - }; - - handleReplyToMessage = (messageId: number, content: string): void => { - this.callbacks.onReplyToMessage(messageId, content); - }; - - handleProfileClick = (): void => { - this.callbacks.onProfileClick(); - }; + abstract handleEditMessage(messageId: number, content: string): Promise; + abstract handleDeleteMessage(messageId: number): Promise; + abstract handleProfileClick(): void; } diff --git a/frontend/src/ui/panels/PublicChatPanel.ts b/frontend/src/ui/panels/PublicChatPanel.ts index fe997ac..8bdf755 100644 --- a/frontend/src/ui/panels/PublicChatPanel.ts +++ b/frontend/src/ui/panels/PublicChatPanel.ts @@ -1,4 +1,4 @@ -import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from "./MessagePanel"; +import { MessagePanel } from "./MessagePanel"; import { API_BASE_URL } from "../../core/config"; import { getAuthHeaders } from "../../auth/api"; import { request } from "../../core/websocket"; @@ -10,11 +10,9 @@ export class PublicChatPanel extends MessagePanel { constructor( chatName: string, - currentUser: UserState, - callbacks: MessagePanelCallbacks, - onStateChange: (state: MessagePanelState) => void + currentUser: UserState ) { - super(`public-${chatName}`, currentUser, callbacks, onStateChange); + super(`public-${chatName}`, currentUser); this.updateState({ title: chatName, online: true // Public chats are always "online" @@ -137,4 +135,36 @@ export class PublicChatPanel extends MessagePanel { setAuthToken(authToken: string): void { this.currentUser.authToken = authToken; } + + async handleEditMessage(messageId: number, content: string): Promise { + if (!this.currentUser.authToken) return; + try { + await request({ + type: "editMessage", + data: { + message_id: messageId, + content: content + }, + credentials: { + scheme: "Bearer", + credentials: this.currentUser.authToken + } + }); + } catch (error) { + console.error("Failed to edit message:", error); + } + } + + async handleDeleteMessage(id: number): Promise { + await request({ + type: "deleteMessage", + data: { message_id: id }, + credentials: { + scheme: "Bearer", + credentials: this.currentUser.authToken! + } + }); + } + + handleProfileClick(): void {} } \ No newline at end of file diff --git a/frontend/src/ui/state.ts b/frontend/src/ui/state.ts index 377d8ac..bf31eb4 100644 --- a/frontend/src/ui/state.ts +++ b/frontend/src/ui/state.ts @@ -277,37 +277,7 @@ export const useAppState = create((set, get) => ({ // Create or get public chat panel let publicChatPanel = chat.publicChatPanel; if (!publicChatPanel) { - const callbacks = { - onSendMessage: (_content: string) => {}, - onEditMessage: async (messageId: number, content: string) => { - if (!user.authToken) return; - try { - await request({ - type: "editMessage", - data: { - message_id: messageId, - content: content - }, - credentials: { - scheme: "Bearer", - credentials: user.authToken - } - }); - } catch (error) { - console.error("Failed to edit message:", error); - } - }, - onDeleteMessage: (_messageId: number) => {}, - onReplyToMessage: (_messageId: number, _content: string) => {}, - onProfileClick: () => {} - }; - - publicChatPanel = new PublicChatPanel( - chatName, - user, - callbacks, - () => {} // State change handled by MessagePanelRenderer - ); + publicChatPanel = new PublicChatPanel(chatName, user); } else { publicChatPanel.setChatName(chatName); publicChatPanel.setAuthToken(user.authToken); @@ -346,19 +316,7 @@ export const useAppState = create((set, get) => ({ // Create or get DM panel let dmPanel = chat.dmPanel; if (!dmPanel) { - const callbacks = { - onSendMessage: (_content: string) => {}, - onEditMessage: (_messageId: number, _content: string) => {}, - onDeleteMessage: (_messageId: number) => {}, - onReplyToMessage: (_messageId: number, _content: string) => {}, - onProfileClick: () => {} - }; - - dmPanel = new DMPanel( - user, - callbacks, - () => {} // State change handled by MessagePanelRenderer - ); + dmPanel = new DMPanel(user); } else { dmPanel.setAuthToken(user.authToken); } From 0fbdcd04c94d7a3ca54373a1d20085e0db7f1994 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 25 Sep 2025 18:47:29 +0300 Subject: [PATCH 3/8] Implement file drag & drop --- backend/routes/messaging.py | 8 +-- frontend/src/resources/css/_chat.scss | 41 ++++++++++++ .../ui/components/chat/ChatInputWrapper.tsx | 16 ++++- .../components/chat/MessagePanelRenderer.tsx | 65 ++++++++++++++++++- .../core/animations/AnimatedHeight.tsx | 20 ++---- .../core/animations/AnimatedOpacity.tsx | 41 ++++++++++++ .../ui/components/core/animations/types.d.ts | 10 +++ 7 files changed, 181 insertions(+), 20 deletions(-) create mode 100644 frontend/src/ui/components/core/animations/AnimatedOpacity.tsx create mode 100644 frontend/src/ui/components/core/animations/types.d.ts diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index e17e0eb..b03c0e4 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -41,11 +41,9 @@ def convert_message(msg: Message) -> dict: "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, + "path": f"/api/uploads/files/normal/{Path(f.path).name}", + "id": f.id, + "message_id": f.message_id } for f in (msg.files or []) ] diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index 6d5087b..ad46b9e 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -65,6 +65,7 @@ display: flex; flex-direction: column; height: 100%; + position: relative; .chat-header { padding: 16px; @@ -277,6 +278,46 @@ } } + .file-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + background: rgba(0, 0, 0, 0.5); + + z-index: 100; + + backdrop-filter: blur(20px); + + .file-overlay-wrapper { + border-radius: 30px; + outline: 3px dashed $color-dark-primary; + outline-offset: -20px; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + + .file-overlay-inner { + display: flex; + gap: 12px; + align-items: center; + padding: 12px 16px; + background: rgba(18, 18, 18, 0.8); + border: 1px solid $color-dark-surface-container-high; + border-radius: 12px; + color: $color-dark-on-surface; + + mdui-icon { + color: $color-dark-primary; + } + } + } + } + .chat-input-wrapper { position: relative; margin: 0 20px 20px 20px; diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index e7b419d..9f5cc5e 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -16,6 +16,7 @@ interface ChatInputWrapperProps { editVisible?: boolean; onClearEdit?: () => void; onCloseEdit?: () => void; + onProvideFileAdder?: (adder: (files: File[]) => void) => void; } export function ChatInputWrapper( @@ -29,7 +30,8 @@ export function ChatInputWrapper( editingMessage, editVisible = false, onClearEdit, - onCloseEdit + onCloseEdit, + onProvideFileAdder }: ChatInputWrapperProps ) { const [message, setMessage] = useState(""); @@ -37,6 +39,18 @@ export function ChatInputWrapper( const [attachmentsVisible, setAttachmentsVisible] = useState(false); const [errorOpen, setErrorOpen] = useState(false); + // Expose a way for parent to programmatically add files + useEffect(() => { + if (onProvideFileAdder) { + const addFiles = (files: File[]) => { + if (!files || files.length === 0) return; + setSelectedFiles(prev => [...prev, ...files]); + setAttachmentsVisible(true); + }; + onProvideFileAdder(addFiles); + } + }, [onProvideFileAdder]); + // When entering edit mode, preload the message content useEffect(() => { if (editingMessage) { diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index 76939f0..7febc76 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -5,6 +5,7 @@ import { ChatInputWrapper } from "./ChatInputWrapper"; import { setGlobalMessageHandler } from "../../../core/websocket"; import type { Message } from "../../../core/types"; import defaultAvatar from "../../../resources/images/default-avatar.png"; +import AnimatedOpacity from "../core/animations/AnimatedOpacity"; interface MessagePanelRendererProps { panel: MessagePanel | null; @@ -22,6 +23,20 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen const [editVisible, setEditVisible] = useState(Boolean(editMessage)); const [pendingAction, setPendingAction] = useState(null); + // Drag & drop + const [isDragging, setIsDragging] = useState(false); + const dragCounterRef = useRef(0); + const addFilesRef = useRef void)>(null); + + useEffect(() => { + if (!panel || !panelState) return; + + return () => { + dragCounterRef.current = 0; + setIsDragging(false); + }; + }, [panel, panelState]); + useEffect(() => { if (replyTo) { setReplyToVisible(true); @@ -116,7 +131,41 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen return (
-
+
{ + if (!e.dataTransfer) return; + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current += 1; + // Only show overlay when actual files are dragged + const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files"); + if (hasFiles) setIsDragging(true); + }} + onDragOver={(e) => { + if (!e.dataTransfer) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + }} + onDragLeave={(e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); + if (dragCounterRef.current === 0) setIsDragging(false); + }} + onDrop={(e) => { + if (!e.dataTransfer) return; + e.preventDefault(); + e.stopPropagation(); + const files = Array.from(e.dataTransfer.files || []); + if (files.length > 0 && addFilesRef.current) { + addFilesRef.current(files); + } + setIsDragging(false); + dragCounterRef.current = 0; + }}>
)} + + e.preventDefault()} + onDrop={(e) => e.preventDefault()}> +
+
+ + Отпустите файл(ы) для добавления +
+
+
{ @@ -213,6 +275,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen setPendingAction(null); } }} + onProvideFileAdder={(adder) => { addFilesRef.current = adder; }} />
diff --git a/frontend/src/ui/components/core/animations/AnimatedHeight.tsx b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx index b4436bc..4c735f5 100644 --- a/frontend/src/ui/components/core/animations/AnimatedHeight.tsx +++ b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx @@ -1,17 +1,10 @@ -import { useEffect, useState, useRef, type ReactNode } from "react"; +import { useEffect, useState, useRef } from "react"; +import type { AnimatedPropertyProps } from "./types"; -export interface AnimatedHeightProps { - visible: any; - duration?: number; - onFinish?: () => void - children?: ReactNode; -} - -export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children }: AnimatedHeightProps) { +export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children, ...props }: AnimatedPropertyProps) { const [height, setHeight] = useState("0px"); const [shouldRender, setShouldRender] = useState(visible); const [isAnimating, setIsAnimating] = useState(false); - const contentRef = useRef(null); const measureRef = useRef(null); useEffect(() => { @@ -50,7 +43,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi }, duration * 1000); } } - }, [visible, duration, shouldRender]); + }, [visible, shouldRender]); // Don't render if not visible and not animating if (!visible && !shouldRender && !isAnimating) { @@ -59,11 +52,12 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi return (
diff --git a/frontend/src/ui/components/core/animations/AnimatedOpacity.tsx b/frontend/src/ui/components/core/animations/AnimatedOpacity.tsx new file mode 100644 index 0000000..2ce6ad4 --- /dev/null +++ b/frontend/src/ui/components/core/animations/AnimatedOpacity.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import type { AnimatedPropertyProps } from "./types"; + +export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, children, ...props }: AnimatedPropertyProps) { + const [opacity, setOpacity] = useState(visible ? 1 : 0); + const [shouldRender, setShouldRender] = useState(visible); + + useEffect(() => { + if (visible) { + setShouldRender(true); + setOpacity(0); + + // Wait for content to render, then animate in + const id = setTimeout(() => { + setOpacity(1); + }, 10); + return () => clearTimeout(id); + } else { + setOpacity(0); + + const id = setTimeout(() => { + setShouldRender(false); + if (onFinish) { + onFinish(); + } + }, duration * 1000); + return () => clearTimeout(id); + } + }, [visible, duration, onFinish]); + + return shouldRender && ( +
{children}
+ ); +} \ No newline at end of file diff --git a/frontend/src/ui/components/core/animations/types.d.ts b/frontend/src/ui/components/core/animations/types.d.ts new file mode 100644 index 0000000..f431740 --- /dev/null +++ b/frontend/src/ui/components/core/animations/types.d.ts @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +export interface BaseAnimatedPropertyProps { + visible: any; + duration?: number; + onFinish?: () => void + children?: ReactNode; +} + +export type AnimatedPropertyProps = BaseAnimatedPropertyProps & React.ComponentPropsWithRef<"div"> \ No newline at end of file From f0925564c698dff7e5fcc7f975f611d819ccf584 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 25 Sep 2025 22:40:45 +0300 Subject: [PATCH 4/8] Improve code and type safety --- backend/models.py | 1 + backend/routes/messaging.py | 4 +- frontend/electron.d.ts | 14 +-- frontend/electron/main.ts | 3 +- frontend/electron/preload.ts | 8 +- frontend/src/api/dmApi.ts | 4 +- frontend/src/core/types.d.ts | 89 +++++++++++++++---- frontend/src/core/websocket.ts | 8 +- .../ui/components/chat/ChatInputWrapper.tsx | 39 ++++---- frontend/src/ui/components/chat/Message.tsx | 12 +-- .../core/animations/AnimatedHeight.tsx | 36 ++++---- frontend/src/ui/panels/DMPanel.ts | 15 ++-- frontend/src/ui/panels/PublicChatPanel.ts | 13 +-- frontend/src/utils/push-notifications.ts | 9 +- 14 files changed, 156 insertions(+), 99 deletions(-) diff --git a/backend/models.py b/backend/models.py index c50210e..4e2b703 100644 --- a/backend/models.py +++ b/backend/models.py @@ -45,6 +45,7 @@ class MessageFile(Base): id = Column(Integer, primary_key=True, index=True) message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True) path = Column(Text, nullable=False) + name = Column(Text, nullable=False) message = relationship("Message", back_populates="files") diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index b03c0e4..3b09e29 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -43,6 +43,7 @@ def convert_message(msg: Message) -> dict: { "path": f"/api/uploads/files/normal/{Path(f.path).name}", "id": f.id, + "name": f.name, "message_id": f.message_id } for f in (msg.files or []) @@ -64,7 +65,7 @@ async def send_message( # Expect JSON: {"type":"text","data":{"content": str}, "reply_to_id": number|null} try: obj = json.loads(payload) - content = obj.get("data", {}).get("content", "") + content = obj.get("content", "") reply_to_id = obj.get("reply_to_id", None) request = SendMessageRequest(content=content, reply_to_id=reply_to_id) except Exception: @@ -147,6 +148,7 @@ async def send_message( mf = MessageFile( message_id=new_message.id, + name=original_name, path=str(out_path) ) db.add(mf) diff --git a/frontend/electron.d.ts b/frontend/electron.d.ts index a825785..5dd2c3c 100644 --- a/frontend/electron.d.ts +++ b/frontend/electron.d.ts @@ -1,13 +1,15 @@ export type Platform = "win32" | "darwin" | "linux" +export interface NotificationShowOptions { + title: string; + body: string; + icon?: string; + tag?: string; +} + export interface ElectronNotifications { requestPermission: () => Promise; - show: (options: { - title: string; - body: string; - icon?: string; - tag?: string; - }) => Promise; + show: (options: NotificationShowOptions) => Promise; } export interface ElectronInterface { diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index cc9b44b..e5aff25 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, Notification, ipcMain } from 'electron'; import path from "node:path"; +import { NotificationShowOptions } from '../electron'; let mainWindow: BrowserWindow | null = null; @@ -35,7 +36,7 @@ app.whenReady().then(() => { }); // Handle showing notifications - ipcMain.handle('show-notification', async (event, options) => { + ipcMain.handle('show-notification', async (event, options: NotificationShowOptions) => { if (Notification.isSupported()) { try { const notification = new Notification({ diff --git a/frontend/electron/preload.ts b/frontend/electron/preload.ts index e1ad02c..f4f86f3 100644 --- a/frontend/electron/preload.ts +++ b/frontend/electron/preload.ts @@ -1,13 +1,11 @@ import { contextBridge, ipcRenderer } from "electron"; import type { ElectronInterface, Platform } from "../electron"; -const electronInterface: ElectronInterface = { +contextBridge.exposeInMainWorld("electronInterface", { desktop: true, platform: process.platform as Platform, notifications: { requestPermission: () => ipcRenderer.invoke('request-notification-permission'), - show: (options: any) => ipcRenderer.invoke('show-notification', options) + show: (options) => ipcRenderer.invoke('show-notification', options) } -} - -contextBridge.exposeInMainWorld("electronInterface", electronInterface); \ No newline at end of file +} satisfies ElectronInterface); \ No newline at end of file diff --git a/frontend/src/api/dmApi.ts b/frontend/src/api/dmApi.ts index 86194a9..84d6347 100644 --- a/frontend/src/api/dmApi.ts +++ b/frontend/src/api/dmApi.ts @@ -5,7 +5,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/s import { randomBytes } from "../utils/crypto/kdf"; import { getCurrentKeys } from "../auth/crypto"; import { request } from "../core/websocket"; -import type { SendDMRequest, DmEnvelope, User, DMEditWebSocketMessage, DmEncryptedJSON, BaseDmEnvelope } from "../core/types"; +import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "../core/types"; import { b64, ub64 } from "../utils/utils"; export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { @@ -159,7 +159,7 @@ export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, wrappedMk: b64(wrap.ciphertext), salt: b64(wkSalt) } - } as DMEditWebSocketMessage); + } as DMEditRequest); } export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise { diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 7d8d852..b5214ec 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -235,10 +235,10 @@ export interface DmEncryptedJSON { * @property {any} [data] - Message payload data * @property {WebSocketError} [error] - Error information if applicable */ -export interface WebSocketMessage { +export interface WebSocketMessage { type: string; credentials?: WebSocketCredentials; - data?: any; + data?: T; error?: WebSocketError; } @@ -264,26 +264,81 @@ export interface WebSocketCredentials { credentials: string; } -export interface DMEditWebSocketMessage extends WebSocketMessage { - type: "dmEdit", - data: { - id: number; - iv: string; - ciphertext: string; - iv2: string; - wrappedMk: string; - salt: string; - } -} - export interface Attachment { path: string; encrypted: boolean; - filename?: string; - content_type?: string; - size?: number; + name: string; } +// ----------------------- +// WebSocket message types +// ----------------------- + +// Utils +export interface DMEditPayload { + id: number; + iv: string; + ciphertext: string; + iv2: string; + wrappedMk: string; + salt: string; +} + +// Requests +export interface DMEditRequest extends WebSocketMessage { + type: "dmEdit", + credentials: WebSocketCredentials; + data: DMEditPayload +} + +export interface SendMessageRequest extends WebSocketMessage { + type: "sendMessage", + credentials: WebSocketCredentials; + data: { + content: string; + reply_to_id: number | null; + } +} + +// Messages +export interface DMNewWebSocketMessage extends WebSocketMessage { + type: "dmNew", + data: DmEnvelope +} + +export interface DMEditedWebSocketMessage extends WebSocketMessage { + type: "dmEdited", + data: DMEditPayload +} + +export interface DMDeletedWebSocketMessage extends WebSocketMessage { + type: "dmDeleted", + data: { + id: number; + } +} + +export interface MessageEditedWebSocketMessage extends WebSocketMessage { + type: "messageEdited", + data: Partial & { id: number } +} + +export interface MessageDeletedWebSocketMessage extends WebSocketMessage { + type: "messageDeleted", + data: { + message_id: number; + } +} + +export interface NewMessageWebSocketMessage extends WebSocketMessage { + type: "newMessage", + data: Message +} + +// Shared types +export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage +export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage + // ----------- // Encrypted message JSON (plaintext structure before encryption) // ----------- diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index e062641..c881cf3 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -33,17 +33,17 @@ export let websocket: WebSocket = create(); * Global WebSocket message handler reference * This will be set by the active panel to handle incoming messages */ -let globalMessageHandler: ((response: WebSocketMessage) => void) | null = null; +let globalMessageHandler: ((response: WebSocketMessage) => void) | null = null; /** * Set the global WebSocket message handler * @param handler - Function to handle WebSocket messages */ -export function setGlobalMessageHandler(handler: ((response: WebSocketMessage) => void) | null): void { +export function setGlobalMessageHandler(handler: ((response: WebSocketMessage) => void) | null): void { globalMessageHandler = handler; } -export function request(payload: WebSocketMessage): Promise { +export function request(payload: WebSocketMessage): Promise> { console.log("WebSocket request:", payload); return new Promise((resolve, reject) => { function requestInner() { @@ -95,7 +95,7 @@ async function onError() { websocket.addEventListener("message", (e) => { try { - const response: WebSocketMessage = JSON.parse(e.data); + const response: WebSocketMessage = JSON.parse(e.data); // Route message to global handler if set if (globalMessageHandler) { diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index 9f5cc5e..e308f98 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -4,6 +4,7 @@ import { RichTextArea } from "../core/RichTextArea"; import type { Message } from "../../../core/types"; import Quote from "../core/Quote"; import AnimatedHeight from "../core/animations/AnimatedHeight"; +import { useImmer } from "use-immer"; interface ChatInputWrapperProps { onSendMessage: (message: string, files: File[]) => void; @@ -35,7 +36,7 @@ export function ChatInputWrapper( }: ChatInputWrapperProps ) { const [message, setMessage] = useState(""); - const [selectedFiles, setSelectedFiles] = useState([]); + const [selectedFiles, setSelectedFiles] = useImmer([]); const [attachmentsVisible, setAttachmentsVisible] = useState(false); const [errorOpen, setErrorOpen] = useState(false); @@ -44,8 +45,7 @@ export function ChatInputWrapper( if (onProvideFileAdder) { const addFiles = (files: File[]) => { if (!files || files.length === 0) return; - setSelectedFiles(prev => [...prev, ...files]); - setAttachmentsVisible(true); + setSelectedFiles(draft => { draft.push(...files) }); }; onProvideFileAdder(addFiles); } @@ -53,18 +53,12 @@ export function ChatInputWrapper( // When entering edit mode, preload the message content useEffect(() => { - if (editingMessage) { - setMessage(editingMessage.content || ""); - } else { - setMessage(""); - } + setMessage(editingMessage ? editingMessage.content || "" : ""); }, [editingMessage]); useEffect(() => { - if (selectedFiles.length > 0) { - setAttachmentsVisible(true); - } - }, [selectedFiles]) + setAttachmentsVisible(selectedFiles.length > 0); + }, [selectedFiles]); const handleSubmit = async (e: React.FormEvent | Event) => { e.preventDefault(); @@ -94,10 +88,9 @@ export function ChatInputWrapper( const input = document.createElement("input"); input.type = "file"; input.multiple = true; - input.onchange = () => { - const files = Array.from(input.files || []); - setSelectedFiles(files); - }; + input.addEventListener("change", () => { + setSelectedFiles(draft => { draft.push(...Array.from(input.files || [])) }); + }); input.click(); } @@ -133,16 +126,22 @@ export function ChatInputWrapper(
- {selectedFiles.map((f, i) => ( + {selectedFiles.map((file, i) => ( setSelectedFiles(prev => prev.filter((_, idx) => idx !== i))} + title={`${file.name} (${Math.round(file.size/1024/1024)} MB)`} + onClick={() => { + if (selectedFiles.length == 1) { + setAttachmentsVisible(false); + } else { + setSelectedFiles(draft => { draft.splice(i) }) + } + }} > - {f.name} + {file.name} ))}
diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index 5f6d56b..ae7588a 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -142,16 +142,16 @@ 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 || "")); + const isImage = !file.encrypted && /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || ""); const downloadUrl = decryptedFiles.get(file.path) || file.path; return ( diff --git a/frontend/src/ui/components/core/animations/AnimatedHeight.tsx b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx index 4c735f5..60c7e86 100644 --- a/frontend/src/ui/components/core/animations/AnimatedHeight.tsx +++ b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx @@ -22,26 +22,24 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi setIsAnimating(false); }, duration * 1000); }, 0); - } else { - if (shouldRender) { - setIsAnimating(true); - if (measureRef.current) { - const contentHeight = measureRef.current.scrollHeight; - setHeight(`${contentHeight}px`); - // Force a reflow before animating to 0 - requestAnimationFrame(() => { - setHeight("0px"); - }); - } - // Hide content after animation completes - setTimeout(() => { - setShouldRender(false); - setIsAnimating(false); - if (onFinish) { - onFinish(); - } - }, duration * 1000); + } else if (shouldRender) { + setIsAnimating(true); + if (measureRef.current) { + const contentHeight = measureRef.current.scrollHeight; + setHeight(`${contentHeight}px`); + // Force a reflow before animating to 0 + requestAnimationFrame(() => { + setHeight("0px"); + }); } + // Hide content after animation completes + setTimeout(() => { + setShouldRender(false); + setIsAnimating(false); + if (onFinish) { + onFinish(); + } + }, duration * 1000); } }, [visible, shouldRender]); diff --git a/frontend/src/ui/panels/DMPanel.ts b/frontend/src/ui/panels/DMPanel.ts index 14af6de..074c952 100644 --- a/frontend/src/ui/panels/DMPanel.ts +++ b/frontend/src/ui/panels/DMPanel.ts @@ -7,7 +7,7 @@ import { editDmEnvelope, deleteDmEnvelope } from "../../api/dmApi"; -import type { DmEncryptedJSON, DmEnvelope, EncryptedMessageJson, Message, WebSocketMessage } from "../../core/types"; +import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../core/types"; import type { UserState } from "../state"; export interface DMPanelData { @@ -58,19 +58,14 @@ export class DMPanel extends MessagePanel { } } catch {} - const dmMsg: Message & { dmEnvelope?: { salt: string; iv2: string; wrappedMk: string } } = { + const dmMsg: Message = { id: env.id, content: content, username: username, timestamp: env.timestamp, is_read: false, is_edited: false, - files: env.files?.map(file => { return {"filename": file.name, "encrypted": true, "path": file.path} }) || [], - dmEnvelope: { - salt: env.salt, - iv2: env.iv2, - wrappedMk: env.wrappedMk - } + files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [] }; if (reply_to_id) { @@ -165,9 +160,9 @@ export class DMPanel extends MessagePanel { } // Handle incoming WebSocket DM messages - handleWebSocketMessage = async (response: WebSocketMessage): Promise => { + handleWebSocketMessage = async (response: DMWebSocketMessage): Promise => { if (response.type === "dmNew" && this.dmData) { - const envelope = response.data as DmEnvelope; + const envelope = response.data; // If this is for the active DM conversation if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) { diff --git a/frontend/src/ui/panels/PublicChatPanel.ts b/frontend/src/ui/panels/PublicChatPanel.ts index 8bdf755..ca9a403 100644 --- a/frontend/src/ui/panels/PublicChatPanel.ts +++ b/frontend/src/ui/panels/PublicChatPanel.ts @@ -2,7 +2,7 @@ import { MessagePanel } from "./MessagePanel"; import { API_BASE_URL } from "../../core/config"; import { getAuthHeaders } from "../../auth/api"; import { request } from "../../core/websocket"; -import type { Message, WebSocketMessage } from "../../core/types"; +import type { ChatWebSocketMessage, Message, SendMessageRequest } from "../../core/types"; import type { UserState } from "../state"; export class PublicChatPanel extends MessagePanel { @@ -65,7 +65,7 @@ export class PublicChatPanel extends MessagePanel { try { if (files.length === 0) { const response = await request({ - data: { + data: { content: content.trim(), reply_to_id: replyToId ?? null }, @@ -74,13 +74,16 @@ export class PublicChatPanel extends MessagePanel { credentials: this.currentUser.authToken }, type: "sendMessage" - }); + } satisfies SendMessageRequest); 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 })); + form.append("payload", JSON.stringify({ + content: content.trim(), + reply_to_id: replyToId ?? null + } satisfies SendMessageRequest["data"])); for (const f of files) form.append("files", f, f.name); const res = await fetch(`${API_BASE_URL}/send_message`, { method: "POST", @@ -97,7 +100,7 @@ export class PublicChatPanel extends MessagePanel { } // Handle incoming WebSocket messages - handleWebSocketMessage = (response: WebSocketMessage): void => { + handleWebSocketMessage = (response: ChatWebSocketMessage): void => { switch (response.type) { case 'messageEdited': if (response.data) { diff --git a/frontend/src/utils/push-notifications.ts b/frontend/src/utils/push-notifications.ts index 00ec50b..eb8cf5d 100644 --- a/frontend/src/utils/push-notifications.ts +++ b/frontend/src/utils/push-notifications.ts @@ -1,7 +1,7 @@ import { API_BASE_URL } from "../core/config"; import { isElectron } from "../electron/electron"; import { websocket } from "../core/websocket"; -import type { WebSocketMessage } from "../core/types"; +import type { NewMessageWebSocketMessage, WebSocketMessage } from "../core/types"; export interface PushSubscriptionData { endpoint: string; @@ -124,10 +124,11 @@ async function showMessageNotification(message: any): Promise { } } -async function handleWebSocketMessage(response: WebSocketMessage): Promise { +async function handleWebSocketMessage(response: WebSocketMessage): Promise { // Handle notifications for new messages if (response.type === "newMessage" && response.data) { - await showMessageNotification(response.data); + const newResponse = response as NewMessageWebSocketMessage; + await showMessageNotification(newResponse.data); } } @@ -242,7 +243,7 @@ export async function startElectronReceiver(): Promise { // Add our own message listener to the existing WebSocket messageListener = (event: MessageEvent) => { try { - const response: WebSocketMessage = JSON.parse(event.data); + const response: WebSocketMessage = JSON.parse(event.data); handleWebSocketMessage(response); } catch (error) { console.error('Failed to parse WebSocket message:', error); From 8a6b7903af33ff4d84cccc87eaa59df3bf5d5f19 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 25 Sep 2025 23:38:31 +0300 Subject: [PATCH 5/8] Implement image preview --- backend/routes/messaging.py | 4 +- frontend/src/core/types.d.ts | 4 + frontend/src/resources/css/_chat.scss | 36 ++ .../src/ui/components/chat/ChatMessages.tsx | 3 +- frontend/src/ui/components/chat/Message.tsx | 385 +++++++++++++----- .../components/chat/MessagePanelRenderer.tsx | 3 +- frontend/src/ui/panels/DMPanel.ts | 8 +- 7 files changed, 338 insertions(+), 105 deletions(-) diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 3b09e29..9e6b535 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -256,7 +256,7 @@ async def dm_send( provided = None original_name = provided or Path(file.filename or "file").name # Save using provided/original name to allow client to reference path directly - safe_name = original_name + safe_name = uid = uuid.uuid4().hex out_name = f"{current_user.id}_{env.recipient_id}_{env.id}_{safe_name}" out_path = FILES_ENCRYPTED_DIR / out_name @@ -270,7 +270,7 @@ async def dm_send( sender_id=current_user.id, recipient_id=env.recipient_id, path=f"/api/uploads/files/encrypted/{out_name}", - name=safe_name + name=original_name ) db.add(df) db.commit() diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index b5214ec..b14d215 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -61,6 +61,10 @@ export interface Message { profile_picture?: string; reply_to?: Message; files?: Attachment[]; + + runtimeData?: { + dmEnvelope?: DmEnvelope; + } } /** diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index ad46b9e..055c66c 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -506,4 +506,40 @@ color: $color-dark-on-surface-variant; } } +} + +// Fullscreen Image Viewer +.fullscreen-image-overlay { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(20px); + z-index: 9999; + opacity: 1; + transition: opacity 0.3s ease; + + &.closing { + opacity: 0; + } + + .fullscreen-animated-image { + position: absolute; + object-fit: contain; + border-radius: 12px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4); + transition: left 0.3s ease, top 0.3s ease, width 0.3s ease, height 0.3s ease; + } + + .fullscreen-controls { + position: absolute; + display: flex; + gap: 8px; + + &.top-right { + top: 12px; + right: 12px; + } + } } \ No newline at end of file diff --git a/frontend/src/ui/components/chat/ChatMessages.tsx b/frontend/src/ui/components/chat/ChatMessages.tsx index e1e3799..c53dd38 100644 --- a/frontend/src/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/ui/components/chat/ChatMessages.tsx @@ -129,8 +129,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o onContextMenu={handleContextMenu} isLoadingProfile={isLoadingProfile} isDm={isDm} - dmRecipientPublicKey={dmRecipientPublicKey} - dmEnvelope={(message as any).dmEnvelope} /> + dmRecipientPublicKey={dmRecipientPublicKey} /> ))} {children}
diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index ae7588a..0c75e05 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -1,10 +1,10 @@ import { formatTime } from "../../../utils/utils"; -import type { Message as MessageType } from "../../../core/types"; +import type { Attachment, Message as MessageType } from "../../../core/types"; import defaultAvatar from "../../../resources/images/default-avatar.png"; import Quote from "../core/Quote"; import { parse } from "marked"; import DOMPurify from "dompurify"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { getCurrentKeys } from "../../../auth/crypto"; import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric"; @@ -20,17 +20,29 @@ interface MessageProps { isLoadingProfile?: boolean; isDm?: boolean; dmRecipientPublicKey?: string; - dmEnvelope?: { - salt: string; - iv2: string; - wrappedMk: string; - }; } -export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey, dmEnvelope }: MessageProps) { +interface Rect { + left: number; + top: number; + width: number; + height: number +} + +export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) { const [formattedMessage, setFormattedMessage] = useState({ __html: "" }); const [decryptedFiles, setDecryptedFiles] = useState>(new Map()); + const [fullscreenImage, setFullscreenImage] = useState<{ + src: string; + name: string; + element: HTMLImageElement; + startRect: Rect; + endRect: Rect; + } | null>(null); + const [isAnimatingOpen, setIsAnimatingOpen] = useState(false); const { user } = useAppState(); + const imageRefs = useRef>(new Map()); + const dmEnvelope = message.runtimeData?.dmEnvelope; useEffect(() => { (async () => { @@ -42,8 +54,30 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo })(); }, [message]); - const decryptFile = async (file: any): Promise => { - if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null; + // Auto-decrypt images in DMs + useEffect(() => { + if (isDm && message.files) { + message.files.forEach(async (file) => { + console.log(file); + const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || ""); + if (isImage && file.encrypted && !decryptedFiles.has(file.path)) { + console.log("Decrypting..."); + const decryptedUrl = await decryptFile(file); + console.log(decryptedUrl); + if (decryptedUrl) { + setDecryptedFiles(prev => new Map(prev).set(file.path, decryptedUrl)); + } + } + }); + } + }, [message.files, isDm, decryptedFiles]); + + const decryptFile = async (file: Attachment): Promise => { + if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) { + debugger; + console.warn("Conditions not met") + return null; + } // Check if already decrypted if (decryptedFiles.has(file.path)) { @@ -90,6 +124,129 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo } }; + const handleImageClick = async (file: Attachment, imageElement: HTMLImageElement) => { + // Use decrypted URL if available, otherwise decrypt first + const decryptedUrl = decryptedFiles.get(file.path); + if (decryptedUrl) { + openFullscreenFromThumb(imageElement, decryptedUrl, file.name || "image"); + } else if (file.encrypted && isDm) { + const newDecryptedUrl = await decryptFile(file); + if (newDecryptedUrl) { + openFullscreenFromThumb(imageElement, newDecryptedUrl, file.name || "image"); + } + } else { + openFullscreenFromThumb(imageElement, file.path, file.name || "image"); + } + }; + + const computeEndRect = (naturalWidth: number, naturalHeight: number): Rect => { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const maxWidth = Math.floor(viewportWidth * 0.9); + const maxHeight = Math.floor(viewportHeight * 0.9); + const widthRatio = maxWidth / naturalWidth; + const heightRatio = maxHeight / naturalHeight; + const scale = Math.min(widthRatio, heightRatio, 1); + const width = Math.round(naturalWidth * scale); + const height = Math.round(naturalHeight * scale); + const left = Math.round((viewportWidth - width) / 2); + const top = Math.round((viewportHeight - height) / 2); + return { left, top, width, height }; + }; + + const openFullscreenFromThumb = (imgEl: HTMLImageElement, src: string, name: string) => { + const rect = imgEl.getBoundingClientRect(); + const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; + const tempImg = new Image(); + tempImg.src = src; + // Hide original while animating + imgEl.style.visibility = "hidden"; + tempImg.onload = () => { + const endRect = computeEndRect(tempImg.naturalWidth, tempImg.naturalHeight); + setFullscreenImage({ + src, + name, + element: imgEl, + startRect, + endRect + }); + // Start animation on next frame to ensure DOM has overlay mounted + requestAnimationFrame(() => setIsAnimatingOpen(true)); + }; + }; + + const closeFullscreen = () => { + // Reverse animation + setIsAnimatingOpen(false); + // Wait for transition to finish + setTimeout(() => { + if (fullscreenImage?.element) { + fullscreenImage.element.style.visibility = "visible"; + } + setFullscreenImage(null); + }, 300); + }; + + const downloadImage = async () => { + if (!fullscreenImage) return; + const { src, name } = fullscreenImage; + try { + if (src.startsWith("blob:")) { + const link = document.createElement("a"); + link.href = src; + link.download = name; + link.click(); + return; + } + + // Fetch with credentials/headers when not a blob URL + const response = await fetch(src, { + headers: user.authToken ? getAuthHeaders(user.authToken) : undefined, + credentials: "include" + }); + if (!response.ok) throw new Error("Failed to download image"); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = name; + link.click(); + URL.revokeObjectURL(url); + } catch (e) { + console.error(e); + } + }; + + const downloadFile = async (file: Attachment) => { + try { + // Prefer decrypted URL if present (DM encrypted case) + const decrypted = decryptedFiles.get(file.path); + if (decrypted) { + const link = document.createElement("a"); + link.href = decrypted; + link.download = file.name || "file"; + link.click(); + return; + } + + // If not decrypted or public file, fetch with credentials/headers + const response = await fetch(file.path, { + headers: user.authToken ? getAuthHeaders(user.authToken) : undefined, + credentials: "include" + }); + if (!response.ok) throw new Error("Failed to download file"); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = file.name || "file"; + link.click(); + URL.revokeObjectURL(url); + } catch (e) { + console.error(e); + } + }; + function handleContextMenu(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); @@ -97,96 +254,128 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo } return ( -
-
- {/* Add profile picture for received messages */} - {!isAuthor && !isDm && ( -
- {message.username} !isLoadingProfile && onProfileClick(message.username)} - style={{ cursor: isLoadingProfile ? "default" : "pointer" }} - className={isLoadingProfile ? "loading" : ""} - onError={(e) => { - const target = e.target as HTMLImageElement; - target.src = defaultAvatar; - }} - /> -
- )} - - {!isAuthor && !isDm && ( -
!isLoadingProfile && onProfileClick(message.username)} - style={{ cursor: isLoadingProfile ? "default" : "pointer" }}> - {message.username} -
- )} - - {/* Add reply preview if this is a reply */} - {message.reply_to && ( - - {message.reply_to.username} - {message.reply_to.content} - - )} - -
- - {message.files && message.files.length > 0 && ( - - {message.files.map((file, idx) => { - const isImage = !file.encrypted && /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || ""); - const downloadUrl = decryptedFiles.get(file.path) || file.path; - return ( - - ); - })} - - )} - -
- {formatTime(message.timestamp)} - {message.is_edited ? " (edited)" : undefined} - - {isAuthor && message.is_read && ( - + <> +
+
+ {/* Add profile picture for received messages */} + {!isAuthor && !isDm && ( +
+ {message.username} !isLoadingProfile && onProfileClick(message.username)} + style={{ cursor: isLoadingProfile ? "default" : "pointer" }} + className={isLoadingProfile ? "loading" : ""} + onError={(e) => { + const target = e.target as HTMLImageElement; + target.src = defaultAvatar; + }} + /> +
)} + + {!isAuthor && !isDm && ( +
!isLoadingProfile && onProfileClick(message.username)} + style={{ cursor: isLoadingProfile ? "default" : "pointer" }}> + {message.username} +
+ )} + + {/* Add reply preview if this is a reply */} + {message.reply_to && ( + + {message.reply_to.username} + {message.reply_to.content} + + )} + +
+ + {message.files && message.files.length > 0 && ( + + {message.files.map((file, idx) => { + const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || ""); + const isEncryptedDm = Boolean(isDm && file.encrypted); + const decryptedUrl = decryptedFiles.get(file.path); + const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined; + + return ( +
+ {isImage ? ( + imageSrc ? ( + { + if (el) imageRefs.current.set(file.path, el); + }} + src={imageSrc} + alt={file.name || "image"} + style={{ maxWidth: "200px", borderRadius: "8px", cursor: "pointer" }} + onClick={(e) => handleImageClick(file, e.currentTarget)} + /> + ) : ( + + Decrypting image... + + ) + ) : ( + { + e.preventDefault(); + await downloadFile(file); + }} + > + + {(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")} + + + )} +
+ ); + })} +
+ )} + +
+ {formatTime(message.timestamp)} + {message.is_edited ? " (edited)" : undefined} + + {isAuthor && message.is_read && ( + + )} +
-
+ + {/* Fullscreen Image Viewer with shared-element like transition */} + {fullscreenImage && ( +
+ {fullscreenImage.name} e.stopPropagation()} + /> +
e.stopPropagation()}> + + +
+
+ )} + ); } diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index 7febc76..cde7c07 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -6,6 +6,7 @@ import { setGlobalMessageHandler } from "../../../core/websocket"; import type { Message } from "../../../core/types"; import defaultAvatar from "../../../resources/images/default-avatar.png"; import AnimatedOpacity from "../core/animations/AnimatedOpacity"; +import type { DMPanel } from "../../panels/DMPanel"; interface MessagePanelRendererProps { panel: MessagePanel | null; @@ -202,7 +203,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen { if (editMessage || editVisible) { setPendingAction({ type: "reply", message: message }); diff --git a/frontend/src/ui/panels/DMPanel.ts b/frontend/src/ui/panels/DMPanel.ts index 074c952..dbaed1b 100644 --- a/frontend/src/ui/panels/DMPanel.ts +++ b/frontend/src/ui/panels/DMPanel.ts @@ -19,7 +19,7 @@ export interface DMPanelData { } export class DMPanel extends MessagePanel { - private dmData: DMPanelData | null = null; + public dmData: DMPanelData | null = null; private messagesLoaded: boolean = false; constructor( @@ -65,7 +65,11 @@ export class DMPanel extends MessagePanel { timestamp: env.timestamp, is_read: false, is_edited: false, - files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [] + files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [], + + runtimeData: { + dmEnvelope: env + } }; if (reply_to_id) { From 13cc9d1c29211b92ea11d4b99996f4a350c21aad Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 26 Sep 2025 14:31:37 +0300 Subject: [PATCH 6/8] Improve design --- frontend/src/resources/css/_chat.scss | 14 +++++++++++ frontend/src/ui/components/chat/Message.tsx | 4 ++-- .../core/animations/AnimatedHeight.tsx | 23 +++++++++++-------- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index 055c66c..3d35ecf 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -243,8 +243,22 @@ a { text-decoration: none; } + + .attachement-image { + max-width: 200px; + border-radius: 8px; + cursor: pointer; + margin-left: 3px; + margin-right: 3px; + margin-bottom: 3px; + + &:last-child { + margin-bottom: 0; + } + } } } + .message-time { font-size: 0.7rem; color: $color-dark-on-surface-variant; diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index 0c75e05..b3098e1 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -314,9 +314,9 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo if (el) imageRefs.current.set(file.path, el); }} src={imageSrc} - alt={file.name || "image"} - style={{ maxWidth: "200px", borderRadius: "8px", cursor: "pointer" }} + alt={file.name || "image"} onClick={(e) => handleImageClick(file, e.currentTarget)} + className="attachement-image" /> ) : ( diff --git a/frontend/src/ui/components/core/animations/AnimatedHeight.tsx b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx index 60c7e86..9b61a31 100644 --- a/frontend/src/ui/components/core/animations/AnimatedHeight.tsx +++ b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx @@ -3,9 +3,10 @@ import type { AnimatedPropertyProps } from "./types"; export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children, ...props }: AnimatedPropertyProps) { const [height, setHeight] = useState("0px"); - const [shouldRender, setShouldRender] = useState(visible); + const [shouldRender, setShouldRender] = useState(!!visible); const [isAnimating, setIsAnimating] = useState(false); const measureRef = useRef(null); + const containerRef = useRef(null); useEffect(() => { if (visible) { @@ -19,6 +20,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi } // Animation complete setTimeout(() => { + setHeight("auto"); setIsAnimating(false); }, duration * 1000); }, 0); @@ -29,7 +31,14 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi setHeight(`${contentHeight}px`); // Force a reflow before animating to 0 requestAnimationFrame(() => { - setHeight("0px"); + // Read layout to ensure the previous height assignment is flushed + if (containerRef.current) { + containerRef.current.offsetHeight; + } + // Use a second frame to ensure the measured pixel height is applied before collapsing + requestAnimationFrame(() => { + setHeight("0px"); + }); }); } // Hide content after animation completes @@ -43,14 +52,10 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi } }, [visible, shouldRender]); - // Don't render if not visible and not animating - if (!visible && !shouldRender && !isAnimating) { - return null; - } - - return ( -
Date: Fri, 26 Sep 2025 14:58:51 +0300 Subject: [PATCH 7/8] Improve image loading --- .cursor/rules/ui.mdc | 3 +- frontend/src/core/init.ts | 4 +- frontend/src/resources/css/_chat.scss | 48 ++++++++++++++++ frontend/src/ui/components/chat/Message.tsx | 63 ++++++++++++++++----- 4 files changed, 103 insertions(+), 15 deletions(-) diff --git a/.cursor/rules/ui.mdc b/.cursor/rules/ui.mdc index c414568..a49b459 100644 --- a/.cursor/rules/ui.mdc +++ b/.cursor/rules/ui.mdc @@ -6,4 +6,5 @@ When you work with UI: 1. Use MDUI components as HTML elements with the name "mdui-***". In JSX/TSX use the same elements and props as in HTML. 2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML. -3. The supporting text slot for MDUI lists is "description". \ No newline at end of file +3. The supporting text slot for MDUI lists is "description". +4. When working with lists/sets in states, use the "useImmer" hook. \ No newline at end of file diff --git a/frontend/src/core/init.ts b/frontend/src/core/init.ts index e18753e..44c4079 100644 --- a/frontend/src/core/init.ts +++ b/frontend/src/core/init.ts @@ -6,5 +6,7 @@ */ import { PRODUCT_NAME } from "./config"; +import { enableMapSet } from "immer"; -document.title = PRODUCT_NAME; \ No newline at end of file +document.title = PRODUCT_NAME; +enableMapSet(); \ No newline at end of file diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index 3d35ecf..c4d30f7 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -238,6 +238,7 @@ .message-attachments { padding: 5px 0 0 0; + overflow: hidden; .attachment { a { @@ -255,6 +256,45 @@ &:last-child { margin-bottom: 0; } + &.loading { + filter: blur(10px); + transition: filter 200ms ease; + } + } + + .attachement-image.placeholder { + background: $color-dark-surface-container-highest; + pointer-events: none; + } + + .image-wrapper { + position: relative; + display: inline-block; + } + + .loading-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.08); + backdrop-filter: blur(6px); + border-radius: 8px; + } + + .preload-image { + position: absolute; + width: 0; + height: 0; + opacity: 0; + pointer-events: none; + } + + .with-icon-gap { + display: inline-flex; + align-items: center; + gap: 8px; } } } @@ -556,4 +596,12 @@ right: 12px; } } + + .progress-wrapper { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + } } \ No newline at end of file diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index b3098e1..0e27b5c 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -11,6 +11,7 @@ import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric" import { getAuthHeaders } from "../../../auth/api"; import { useAppState } from "../../state"; import { ub64 } from "../../../utils/utils"; +import { useImmer } from "use-immer"; interface MessageProps { message: MessageType; @@ -31,7 +32,10 @@ interface Rect { export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) { const [formattedMessage, setFormattedMessage] = useState({ __html: "" }); - const [decryptedFiles, setDecryptedFiles] = useState>(new Map()); + const [decryptedFiles, updateDecryptedFiles] = useImmer>(new Map()); + const [loadedImages, updateLoadedImages] = useImmer>(new Set()); + const [downloadingPaths, updateDownloadingPaths] = useImmer>(new Set()); + const [isDownloadingFullscreen, setIsDownloadingFullscreen] = useState(false); const [fullscreenImage, setFullscreenImage] = useState<{ src: string; name: string; @@ -65,7 +69,9 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo const decryptedUrl = await decryptFile(file); console.log(decryptedUrl); if (decryptedUrl) { - setDecryptedFiles(prev => new Map(prev).set(file.path, decryptedUrl)); + updateDecryptedFiles(draft => { + draft.set(file.path, decryptedUrl); + }); } } }); @@ -85,6 +91,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo } try { + // no-op decrypt indicator removed from UI // Fetch encrypted file const response = await fetch(file.path, { headers: getAuthHeaders(user.authToken!) @@ -116,11 +123,15 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo const blob = new Blob([decrypted.buffer as ArrayBuffer]); const url = URL.createObjectURL(blob); - setDecryptedFiles(prev => new Map(prev).set(file.path, url)); + updateDecryptedFiles(draft => { + draft.set(file.path, url); + }); return url; } catch (error) { console.error("Failed to decrypt file:", error); return null; + } finally { + // no-op decrypt indicator removed from UI } }; @@ -191,11 +202,13 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo if (!fullscreenImage) return; const { src, name } = fullscreenImage; try { + setIsDownloadingFullscreen(true); if (src.startsWith("blob:")) { const link = document.createElement("a"); link.href = src; link.download = name; link.click(); + setIsDownloadingFullscreen(false); return; } @@ -214,11 +227,16 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo URL.revokeObjectURL(url); } catch (e) { console.error(e); + } finally { + setIsDownloadingFullscreen(false); } }; const downloadFile = async (file: Attachment) => { try { + updateDownloadingPaths(draft => { + draft.add(file.path); + }); // Prefer decrypted URL if present (DM encrypted case) const decrypted = decryptedFiles.get(file.path); if (decrypted) { @@ -226,6 +244,9 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo link.href = decrypted; link.download = file.name || "file"; link.click(); + updateDownloadingPaths(draft => { + draft.delete(file.path); + }); return; } @@ -244,6 +265,10 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo URL.revokeObjectURL(url); } catch (e) { console.error(e); + } finally { + updateDownloadingPaths(draft => { + draft.delete(file.path); + }); } }; @@ -304,11 +329,12 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo const isEncryptedDm = Boolean(isDm && file.encrypted); const decryptedUrl = decryptedFiles.get(file.path); const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined; + const isDownloading = downloadingPaths.has(file.path); return (
{isImage ? ( - imageSrc ? ( +
{ if (el) imageRefs.current.set(file.path, el); @@ -316,13 +342,15 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo src={imageSrc} alt={file.name || "image"} onClick={(e) => handleImageClick(file, e.currentTarget)} - className="attachement-image" + onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })} + className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`} /> - ) : ( - - Decrypting image... - - ) + {!loadedImages.has(file.path) && ( +
+ +
+ )} +
) : ( - - {(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")} + + + {isDownloading ? : null} + {(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")} + )} @@ -372,7 +403,13 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo />
e.stopPropagation()}> - + {isDownloadingFullscreen ? ( +
+ +
+ ) : ( + + )}
)} From 8829a01e23152fd57e96f8622aeb3f7d659f3c13 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 26 Sep 2025 15:39:53 +0300 Subject: [PATCH 8/8] Implement database migration --- .gitignore | 5 +- backend/app.py | 13 ++++- backend/migration.py | 92 +++++++++++++++++++++++++++++++ backend/migrations/env.py | 43 +++++++++++++++ backend/migrations/script.py.mako | 23 ++++++++ backend/requirements.txt | 3 +- package.json | 2 +- 7 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 backend/migration.py create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako diff --git a/.gitignore b/.gitignore index ea1679e..214aec6 100644 --- a/.gitignore +++ b/.gitignore @@ -380,4 +380,7 @@ data .vite *.db package-lock.json -dist-electron \ No newline at end of file +dist-electron +backend/migrations/** +!backend/migrations/env.py +!backend/migrations/script.py.mako \ No newline at end of file diff --git a/backend/app.py b/backend/app.py index dd2ee15..b07ee62 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,5 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from migration import run_auto_migration +from db import engine from routes import account, messaging, profile, push @@ -19,4 +21,13 @@ app.add_middleware( app.include_router(account.router) app.include_router(messaging.router) app.include_router(profile.router) -app.include_router(push.router, prefix="/push") \ No newline at end of file +app.include_router(push.router, prefix="/push") + + +@app.on_event("startup") +def _auto_migrate_on_startup(): + try: + run_auto_migration(engine) + except Exception: + # Keep startup resilient; errors should be visible in server logs + pass \ No newline at end of file diff --git a/backend/migration.py b/backend/migration.py new file mode 100644 index 0000000..242eb92 --- /dev/null +++ b/backend/migration.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional +from traceback import format_exc +import hashlib + +from sqlalchemy.engine import Engine + +from alembic import command +from alembic.config import Config + +from models import Base +from constants import DATABASE_URL + + +MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations" +LOCK_FILE = MIGRATIONS_DIR / ".autogen.lock" +SCHEMA_HASH_FILE = MIGRATIONS_DIR / ".schema.hash" + + +def _ensure_alembic_layout() -> None: + """Create a minimal Alembic environment if missing.""" + versions = MIGRATIONS_DIR / "versions" + versions.mkdir(parents=True, exist_ok=True) + + +def _alembic_config() -> Config: + cfg = Config() + cfg.set_main_option("script_location", str(MIGRATIONS_DIR)) + cfg.set_main_option("sqlalchemy.url", DATABASE_URL) + # Provide a minimal ini section so env.py can read config_ini_section + cfg.config_file_name = "alembic.ini" + cfg.set_section_option("alembic", "sqlalchemy.url", DATABASE_URL) + return cfg + + +def _model_schema_fingerprint() -> str: + """Compute a deterministic fingerprint of the current SQLAlchemy model schema.""" + parts: list[str] = [] + md = Base.metadata + for table in sorted(md.tables.values(), key=lambda t: t.name): + parts.append(f"T:{table.name}") + for col in sorted(table.columns, key=lambda c: c.name): + col_type = str(col.type) + parts.append(f"C:{col.name}:{col_type}:N{int(bool(col.nullable))}") + digest = hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest() + return digest + + +def run_auto_migration(engine: Engine) -> None: + """Use Alembic to autogenerate and apply migrations automatically on startup.""" + # Ensure env present + _ensure_alembic_layout() + cfg = _alembic_config() + + try: + # Upgrade existing migrations (if any) first + command.upgrade(cfg, "head") + except Exception: + print("[alembic] upgrade to head failed:\n" + format_exc()) + + # Always attempt autogenerate only when model schema fingerprint changed + try: + # Avoid concurrent autogenerate on dev server reloads + try: + LOCK_FILE.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_RDWR) + os.close(fd) + have_lock = True + except FileExistsError: + have_lock = False + + if have_lock: + try: + new_hash = _model_schema_fingerprint() + old_hash = SCHEMA_HASH_FILE.read_text(encoding="utf-8").strip() if SCHEMA_HASH_FILE.exists() else "" + if new_hash != old_hash: + command.revision(cfg, message="auto", autogenerate=True) + command.upgrade(cfg, "head") + # Update stored fingerprint + SCHEMA_HASH_FILE.write_text(new_hash, encoding="utf-8") + finally: + try: + LOCK_FILE.unlink(missing_ok=True) + except Exception: + pass + except Exception: + print("[alembic] autogenerate failed:\n" + format_exc()) + + diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..13d32b5 --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from sqlalchemy import engine_from_config, pool +from alembic import context +from models import Base + +config = context.config +target_metadata = Base.metadata + + +def _skip_empty_autogenerate(ctx, rev, directives): + # Avoid creating empty migrations when there are no schema changes + if getattr(config, "cmd_opts", None) and getattr(config.cmd_opts, "autogenerate", False): + if directives: + script = directives[0] + if hasattr(script, "upgrade_ops") and script.upgrade_ops.is_empty(): + directives[:] = [] + +def run_migrations_offline(): + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + render_as_batch=True, + process_revision_directives=_skip_empty_autogenerate + ) + with context.begin_transaction(): + context.run_migrations() + +def run_migrations_online(): + connectable = engine_from_config(config.get_section(config.config_ini_section) or {}, prefix="sqlalchemy.", poolclass=pool.NullPool) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + process_revision_directives=_skip_empty_autogenerate + ) + with context.begin_transaction(): + context.run_migrations() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..559967c --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,23 @@ + +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '${up_revision}' +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + +def upgrade(): + pass + +def downgrade(): + pass diff --git a/backend/requirements.txt b/backend/requirements.txt index 966e1b9..5260a46 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,4 +7,5 @@ websockets>=15.0.1 Pillow>=10.0.0 python-multipart>=0.0.6 pywebpush>=1.14.0 -cryptography>=41.0.0 \ No newline at end of file +cryptography>=41.0.0 +alembic>=1.13.2 \ No newline at end of file diff --git a/package.json b/package.json index 7a1dd91..d1499f9 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "backend:run": "cd backend && dotenv -e ../deployment/.env -- ../.venv/bin/fastapi dev --port 8300 main.py", "backend:dependencies": "python3 -m venv .venv && ./.venv/bin/pip3 install -r backend/requirements.txt", "backend:reinstall": "rm -rf .venv && npm run backend:dependencies", - "backend:clean": "rm -rf backend/data", + "backend:clean": "rm -rf backend/data && rm -rf backend/migrations", "frontend:dev": "vite frontend", "frontend:typecheck": "tsc --project frontend", "frontend:build": "npm run frontend:typecheck && vite build frontend",