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/.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/.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/models.py b/backend/models.py index d9e2757..4e2b703 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 @@ -36,6 +36,18 @@ 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) + name = Column(Text, nullable=False) + + message = relationship("Message", back_populates="files") class CryptoPublicKey(Base): @@ -65,7 +77,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/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/backend/routes/messaging.py b/backend/routes/messaging.py index 8780edd..9e6b535 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -1,16 +1,34 @@ 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 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, DMFile 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 +38,39 @@ 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/uploads/files/normal/{Path(f.path).name}", + "id": f.id, + "name": f.name, + "message_id": f.message_id + } + 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("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 +100,77 @@ 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, + name=original_name, + path=str(out_path) + ) + 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 +189,29 @@ 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 +): + 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"]), @@ -96,26 +220,92 @@ async def dm_send(payload: dict, current_user: User = Depends(get_current_user), 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() db.refresh(env) - + + # Save encrypted files if any (no processing) + if files: + # Validate total size + total_size = 0 + for file in files: + if hasattr(file, "size") and file.size is not None: + total_size += int(file.size) + else: + 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") + + 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 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(file.filename or "file").name + # Save using provided/original name to allow client to reference path directly + 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 + + content = await file.read() + with open(out_path, "wb") as f: + f.write(content) + + # 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=original_name + ) + db.add(df) + db.commit() + # 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: + 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(), + "replyToId": env.reply_to_id, + } + } + 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} - -@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": [ @@ -129,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)) @@ -146,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}") @@ -284,7 +465,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"] @@ -312,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() @@ -329,6 +511,7 @@ class MessaggingSocketManager: "iv2": env.iv2_b64, "wrappedMk": env.wrapped_mk_b64, "timestamp": env.timestamp.isoformat(), + "replyToId": env.reply_to_id, } } @@ -361,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() @@ -414,4 +667,36 @@ 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("/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") + path = FILES_NORMAL_DIR / filename + if not path.exists(): + raise HTTPException(status_code=404, detail="File not found") + return FileResponse(str(path)) + + +@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/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 90a6446..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 } 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 { @@ -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", @@ -79,3 +80,92 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey data: payload }); } + +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 DMEditRequest); +} + +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/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/core/types.d.ts b/frontend/src/core/types.d.ts index 5653ca5..b14d215 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -60,6 +60,11 @@ export interface Message { timestamp: string; profile_picture?: string; reply_to?: Message; + files?: Attachment[]; + + runtimeData?: { + dmEnvelope?: DmEnvelope; + } } /** @@ -154,6 +159,7 @@ export interface SendDMRequest { salt: string; iv2: string; wrappedMk: string; + replyToId?: number; } // Responses @@ -173,22 +179,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 // --------------- @@ -201,10 +239,10 @@ export interface FetchDMResponse { * @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; } @@ -230,6 +268,98 @@ export interface WebSocketCredentials { credentials: string; } +export interface Attachment { + path: string; + encrypted: boolean; + 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) +// ----------- + +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/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/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index d1c65e7..c4d30f7 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; @@ -172,28 +173,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 +183,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 { @@ -219,7 +233,70 @@ .quote.reply-preview { user-select: none; - margin-bottom: 10px; + margin: 10px; + } + + .message-attachments { + padding: 5px 0 0 0; + overflow: hidden; + + .attachment { + 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; + } + &.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; + } + } } .message-time { @@ -228,6 +305,7 @@ margin-top: 0.3rem; text-align: right; user-select: none; + margin: 4px 8px 8px 8px; } } @@ -252,16 +330,44 @@ } } } - - .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; + .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; + } } } } @@ -309,6 +415,16 @@ } } + .attachments-preview { + align-items: center; + + .attachments-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + } + .chat-input { flex: 1; display: flex; @@ -331,22 +447,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); + } } } } @@ -437,4 +560,48 @@ 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; + } + } + + .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/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index a027937..e308f98 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -1,11 +1,13 @@ 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"; +import { useImmer } from "use-immer"; interface ChatInputWrapperProps { - onSendMessage: (message: string) => void; + onSendMessage: (message: string, files: File[]) => void; onSaveEdit?: (content: string) => void; replyTo?: Message | null; replyToVisible: boolean; @@ -15,35 +17,83 @@ interface ChatInputWrapperProps { editVisible?: boolean; onClearEdit?: () => void; onCloseEdit?: () => void; + onProvideFileAdder?: (adder: (files: File[]) => void) => 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, + onProvideFileAdder + }: ChatInputWrapperProps +) { const [message, setMessage] = useState(""); + const [selectedFiles, setSelectedFiles] = useImmer([]); + 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(draft => { draft.push(...files) }); + }; + onProvideFileAdder(addFiles); + } + }, [onProvideFileAdder]); // When entering edit mode, preload the message content useEffect(() => { - if (editingMessage) { - setMessage(editingMessage.content || ""); - } else { - setMessage(""); - } + setMessage(editingMessage ? editingMessage.content || "" : ""); }, [editingMessage]); + useEffect(() => { + setAttachmentsVisible(selectedFiles.length > 0); + }, [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.addEventListener("change", () => { + setSelectedFiles(draft => { draft.push(...Array.from(input.files || [])) }); + }); + input.click(); + } + return (
@@ -71,6 +121,34 @@ export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVi
)} + setSelectedFiles([])}> + {selectedFiles.length > 0 && ( +
+ +
+ {selectedFiles.map((file, i) => ( + { + if (selectedFiles.length == 1) { + setAttachmentsVisible(false); + } else { + setSelectedFiles(draft => { draft.splice(i) }) + } + }} + > + + {file.name} + + ))} +
+ setAttachmentsVisible(false)}> +
+ )} +
setMessage(value)} onEnter={handleSubmit} /> - +
+ + +
+ +
Ошибка
+
Общий размер вложений превышает 4 ГБ.
+ setErrorOpen(false)}>Закрыть +
); } diff --git a/frontend/src/ui/components/chat/ChatMessages.tsx b/frontend/src/ui/components/chat/ChatMessages.tsx index c835004..c53dd38 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,8 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o onProfileClick={handleProfileClick} onContextMenu={handleContextMenu} isLoadingProfile={isLoadingProfile} - isDm={isDm} /> + isDm={isDm} + dmRecipientPublicKey={dmRecipientPublicKey} /> ))} {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 e5835be..0e27b5c 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -1,10 +1,17 @@ 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"; +import { getAuthHeaders } from "../../../auth/api"; +import { useAppState } from "../../state"; +import { ub64 } from "../../../utils/utils"; +import { useImmer } from "use-immer"; interface MessageProps { message: MessageType; @@ -13,10 +20,33 @@ interface MessageProps { onContextMenu: (e: React.MouseEvent, message: MessageType) => void; isLoadingProfile?: boolean; isDm?: boolean; + dmRecipientPublicKey?: string; } -export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false }: MessageProps) { - const [formattedMessage, setFormattedMessage] = useState({ __html: DOMPurify.sanitize(message.content).trim() }); +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, 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; + 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 () => { @@ -28,6 +58,220 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo })(); }, [message]); + // 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) { + updateDecryptedFiles(draft => { + draft.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)) { + return decryptedFiles.get(file.path) || null; + } + + try { + // no-op decrypt indicator removed from UI + // 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); + + 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 + } + }; + + 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 { + setIsDownloadingFullscreen(true); + if (src.startsWith("blob:")) { + const link = document.createElement("a"); + link.href = src; + link.download = name; + link.click(); + setIsDownloadingFullscreen(false); + 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); + } 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) { + const link = document.createElement("a"); + link.href = decrypted; + link.download = file.name || "file"; + link.click(); + updateDownloadingPaths(draft => { + draft.delete(file.path); + }); + 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); + } finally { + updateDownloadingPaths(draft => { + draft.delete(file.path); + }); + } + }; + function handleContextMenu(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); @@ -35,57 +279,140 @@ 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} - - )} - -
- -
- {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; + const isDownloading = downloadingPaths.has(file.path); + + return ( +
+ {isImage ? ( +
+ { + if (el) imageRefs.current.set(file.path, el); + }} + src={imageSrc} + alt={file.name || "image"} + onClick={(e) => handleImageClick(file, e.currentTarget)} + onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })} + className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`} + /> + {!loadedImages.has(file.path) && ( +
+ +
+ )} +
+ ) : ( + { + e.preventDefault(); + await downloadFile(file); + }} + > + + + {isDownloading ? : null} + {(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()}> + + {isDownloadingFullscreen ? ( +
+ +
+ ) : ( + + )} +
+
+ )} + ); } diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index 40feee4..cde7c07 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -5,6 +5,8 @@ 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"; +import type { DMPanel } from "../../panels/DMPanel"; interface MessagePanelRendererProps { panel: MessagePanel | null; @@ -22,6 +24,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 +132,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; + }}>
{ if (editMessage || editVisible) { setPendingAction({ type: "reply", message: message }); @@ -169,14 +220,28 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen setEditMessage(message); } }} + onDelete={(id) => panel.handleDeleteMessage(id)} >
)} + + e.preventDefault()} + onDrop={(e) => e.preventDefault()}> +
+
+ + Отпустите файл(ы) для добавления +
+
+
{ - panel.handleSendMessage(text, replyTo?.id); + onSendMessage={(text, files) => { + panel.handleSendMessage(text, replyTo?.id, files); setReplyTo(null); }} onSaveEdit={(content) => { @@ -211,6 +276,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..9b61a31 100644 --- a/frontend/src/ui/components/core/animations/AnimatedHeight.tsx +++ b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx @@ -1,18 +1,12 @@ -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 [shouldRender, setShouldRender] = useState(!!visible); const [isAnimating, setIsAnimating] = useState(false); - const contentRef = useRef(null); const measureRef = useRef(null); + const containerRef = useRef(null); useEffect(() => { if (visible) { @@ -26,44 +20,47 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi } // Animation complete setTimeout(() => { + setHeight("auto"); 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 + } else if (shouldRender) { + setIsAnimating(true); + if (measureRef.current) { + const contentHeight = measureRef.current.scrollHeight; + setHeight(`${contentHeight}px`); + // Force a reflow before animating to 0 + requestAnimationFrame(() => { + // 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 - setTimeout(() => { - setShouldRender(false); - setIsAnimating(false); - if (onFinish) { - onFinish(); - } - }, duration * 1000); + }); } + // Hide content after animation completes + setTimeout(() => { + setShouldRender(false); + setIsAnimating(false); + if (onFinish) { + onFinish(); + } + }, duration * 1000); } - }, [visible, duration, shouldRender]); + }, [visible, shouldRender]); - // Don't render if not visible and not animating - if (!visible && !shouldRender && !isAnimating) { - return null; - } - - 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 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 d97d968..dbaed1b 100644 --- a/frontend/src/ui/panels/DMPanel.ts +++ b/frontend/src/ui/panels/DMPanel.ts @@ -1,10 +1,13 @@ -import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from "./MessagePanel"; +import { MessagePanel } from "./MessagePanel"; import { fetchDMHistory, decryptDm, - sendDMViaWebSocket + sendDMViaWebSocket, + sendDmWithFiles, + editDmEnvelope, + deleteDmEnvelope } from "../../api/dmApi"; -import type { Message, WebSocketMessage } from "../../core/types"; +import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../core/types"; import type { UserState } from "../state"; export interface DMPanelData { @@ -16,15 +19,13 @@ export interface DMPanelData { } export class DMPanel extends MessagePanel { - private dmData: DMPanelData | null = null; + public dmData: DMPanelData | null = null; 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 { @@ -41,6 +42,44 @@ 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 = { + id: env.id, + content: content, + username: username, + timestamp: env.timestamp, + is_read: false, + is_edited: false, + files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [], + + runtimeData: { + dmEnvelope: env + } + }; + + 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; @@ -52,18 +91,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; @@ -88,16 +117,35 @@ 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 - ); + 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, + json, + this.currentUser.authToken + ); + } else { + await sendDmWithFiles( + this.dmData.userId, + this.dmData.publicKey, + json, + files, + this.currentUser.authToken + ); + } } catch (error) { console.error("Failed to send DM:", error); } @@ -116,27 +164,18 @@ 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 { senderId, recipientId, ...envelope } = response.data; + const envelope = response.data; // 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) { @@ -144,6 +183,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 @@ -179,4 +255,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 da704c3..b1e74b0 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; @@ -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,15 +37,13 @@ export abstract class MessagePanel { isTyping: false }; this.currentUser = currentUser; - this.callbacks = callbacks; - this.onStateChange = onStateChange; } // Abstract methods that must be implemented by subclasses 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,23 +110,10 @@ export abstract class MessagePanel { } // Event handlers - handleSendMessage = (content: string, replyToId?: number): void => { - this.sendMessage(content, replyToId); - }; - - 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(); + handleSendMessage(content: string, replyToId?: number, files: File[] = []): void { + this.sendMessage(content, replyToId, files); }; + 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 dd5f2f7..ca9a403 100644 --- a/frontend/src/ui/panels/PublicChatPanel.ts +++ b/frontend/src/ui/panels/PublicChatPanel.ts @@ -1,8 +1,8 @@ -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"; -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 { @@ -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" @@ -61,24 +59,40 @@ 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" + } satisfies SendMessageRequest); + if (response.error) { + console.error("Error sending message:", response.error); + } + } else { + const form = new FormData(); + 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", + 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); @@ -86,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) { @@ -124,4 +138,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); } 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'; 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); 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",