Merge branch 'feature/upload_files'

This commit is contained in:
2025-09-26 15:40:15 +03:00
Unverified
35 changed files with 1889 additions and 557 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"mdui": {
"command": "npx",
"args": ["-y", "@mdui/mcp"]
}
}
}
+2 -1
View File
@@ -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. 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. 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". 3. The supporting text slot for MDUI lists is "description".
4. When working with lists/sets in states, use the "useImmer" hook.
+4 -1
View File
@@ -380,4 +380,7 @@ data
.vite .vite
*.db *.db
package-lock.json package-lock.json
dist-electron dist-electron
backend/migrations/**
!backend/migrations/env.py
!backend/migrations/script.py.mako
+12 -1
View File
@@ -1,5 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from migration import run_auto_migration
from db import engine
from routes import account, messaging, profile, push from routes import account, messaging, profile, push
@@ -19,4 +21,13 @@ app.add_middleware(
app.include_router(account.router) app.include_router(account.router)
app.include_router(messaging.router) app.include_router(messaging.router)
app.include_router(profile.router) app.include_router(profile.router)
app.include_router(push.router, prefix="/push") 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
+92
View File
@@ -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())
+43
View File
@@ -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()
+23
View File
@@ -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
+28 -1
View File
@@ -1,5 +1,5 @@
from sqlalchemy.ext.declarative import declarative_base 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 sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
from db import engine from db import engine
@@ -36,6 +36,18 @@ class Message(Base):
author = relationship("User", back_populates="messages") author = relationship("User", back_populates="messages")
reply_to = relationship("Message", remote_side=[id]) 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): class CryptoPublicKey(Base):
@@ -65,7 +77,22 @@ class DMEnvelope(Base):
salt_b64 = Column(Text, nullable=False) salt_b64 = Column(Text, nullable=False)
iv2_b64 = Column(Text, nullable=False) iv2_b64 = Column(Text, nullable=False)
wrapped_mk_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) 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): class PushSubscription(Base):
+2 -1
View File
@@ -7,4 +7,5 @@ websockets>=15.0.1
Pillow>=10.0.0 Pillow>=10.0.0
python-multipart>=0.0.6 python-multipart>=0.0.6
pywebpush>=1.14.0 pywebpush>=1.14.0
cryptography>=41.0.0 cryptography>=41.0.0
alembic>=1.13.2
+320 -35
View File
@@ -1,16 +1,34 @@
from datetime import datetime from datetime import datetime
import logging 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 fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from constants import OWNER_USERNAME 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 push_service import push_service
from PIL import Image
import io
import json
router = APIRouter() router = APIRouter()
logger = logging.getLogger("uvicorn.error") 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: def convert_message(msg: Message) -> dict:
return { return {
"id": msg.id, "id": msg.id,
@@ -20,16 +38,39 @@ def convert_message(msg: Message) -> dict:
"is_edited": msg.is_edited, "is_edited": msg.is_edited,
"username": msg.author.username, "username": msg.author.username,
"profile_picture": msg.author.profile_picture, "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") @router.post("/send_message")
async def send_message( async def send_message(
request: SendMessageRequest, request: SendMessageRequest | None = None,
current_user: User = Depends(get_current_user), 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: if request.reply_to_id:
# Check if the message being replied to exists # Check if the message being replied to exists
original_message = db.query(Message).filter(Message.id == request.reply_to_id).first() original_message = db.query(Message).filter(Message.id == request.reply_to_id).first()
@@ -59,12 +100,77 @@ async def send_message(
db.commit() db.commit()
db.refresh(new_message) 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 # Send push notifications for public messages
try: try:
await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id) await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id)
except Exception as e: except Exception as e:
logger.error(f"Failed to send push notification for message {new_message.id}: {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)} 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") @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"] required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
for key in required: for key in required:
if key not in payload: if key not in payload:
raise HTTPException(status_code=400, detail=f"Missing {key}") raise HTTPException(status_code=400, detail=f"Missing {key}")
env = DMEnvelope( env = DMEnvelope(
sender_id=current_user.id, sender_id=current_user.id,
recipient_id=int(payload["recipientId"]), 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"], salt_b64=payload["salt"],
iv2_b64=payload["iv2"], iv2_b64=payload["iv2"],
wrapped_mk_b64=payload["wrappedMk"], wrapped_mk_b64=payload["wrappedMk"],
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
) )
db.add(env) db.add(env)
db.commit() db.commit()
db.refresh(env) 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 # Send push notification for DM
try: try:
await push_service.send_dm_notification(db, env, current_user) await push_service.send_dm_notification(db, env, current_user)
except Exception as e: except Exception as e:
logger.error(f"Failed to send push notification for DM {env.id}: {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} return {"status": "ok", "id": env.id}
def convert_envelopes(envs: list[DMEnvelope]):
@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()
return { return {
"status": "ok", "status": "ok",
"messages": [ "messages": [
@@ -129,15 +319,23 @@ async def dm_fetch(since: int | None = None, current_user: User = Depends(get_cu
"iv2": e.iv2_b64, "iv2": e.iv2_b64,
"wrappedMk": e.wrapped_mk_b64, "wrappedMk": e.wrapped_mk_b64,
"timestamp": e.timestamp.isoformat(), "timestamp": e.timestamp.isoformat(),
"files": [{"name": file.name, "path": file.path, "id": file.id} for file in e.files]
} }
for e in envs 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}") @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)): 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) db.query(DMEnvelope)
.filter( .filter(
((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id)) ((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()) .order_by(DMEnvelope.id.asc())
.all() .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}") @router.put("/edit_message/{message_id}")
@@ -284,7 +465,7 @@ class MessaggingSocketManager:
request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) 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({ await self.broadcast({
"type": "newMessage", "type": "newMessage",
"data": response["message"] "data": response["message"]
@@ -312,6 +493,7 @@ class MessaggingSocketManager:
salt_b64=payload["salt"], salt_b64=payload["salt"],
iv2_b64=payload["iv2"], iv2_b64=payload["iv2"],
wrapped_mk_b64=payload["wrappedMk"], wrapped_mk_b64=payload["wrappedMk"],
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
) )
db.add(env) db.add(env)
db.commit() db.commit()
@@ -329,6 +511,7 @@ class MessaggingSocketManager:
"iv2": env.iv2_b64, "iv2": env.iv2_b64,
"wrappedMk": env.wrapped_mk_b64, "wrappedMk": env.wrapped_mk_b64,
"timestamp": env.timestamp.isoformat(), "timestamp": env.timestamp.isoformat(),
"replyToId": env.reply_to_id,
} }
} }
@@ -361,6 +544,76 @@ class MessaggingSocketManager:
await websocket.send_json({"type": type, "data": response}) await websocket.send_json({"type": type, "data": response})
except HTTPException as e: except HTTPException as e:
await self.send_error(websocket, type, 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": elif type == "deleteMessage":
try: try:
current_user = get_current_user_inner() current_user = get_current_user_inner()
@@ -414,4 +667,36 @@ async def chat_websocket(
websocket: WebSocket, websocket: WebSocket,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
await messagingManager.connect(websocket, db) 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))
+8 -6
View File
@@ -1,13 +1,15 @@
export type Platform = "win32" | "darwin" | "linux" export type Platform = "win32" | "darwin" | "linux"
export interface NotificationShowOptions {
title: string;
body: string;
icon?: string;
tag?: string;
}
export interface ElectronNotifications { export interface ElectronNotifications {
requestPermission: () => Promise<NotificationPermission>; requestPermission: () => Promise<NotificationPermission>;
show: (options: { show: (options: NotificationShowOptions) => Promise<boolean>;
title: string;
body: string;
icon?: string;
tag?: string;
}) => Promise<boolean>;
} }
export interface ElectronInterface { export interface ElectronInterface {
+2 -1
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow, Notification, ipcMain } from 'electron'; import { app, BrowserWindow, Notification, ipcMain } from 'electron';
import path from "node:path"; import path from "node:path";
import { NotificationShowOptions } from '../electron';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
@@ -35,7 +36,7 @@ app.whenReady().then(() => {
}); });
// Handle showing notifications // Handle showing notifications
ipcMain.handle('show-notification', async (event, options) => { ipcMain.handle('show-notification', async (event, options: NotificationShowOptions) => {
if (Notification.isSupported()) { if (Notification.isSupported()) {
try { try {
const notification = new Notification({ const notification = new Notification({
+3 -5
View File
@@ -1,13 +1,11 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import type { ElectronInterface, Platform } from "../electron"; import type { ElectronInterface, Platform } from "../electron";
const electronInterface: ElectronInterface = { contextBridge.exposeInMainWorld("electronInterface", {
desktop: true, desktop: true,
platform: process.platform as Platform, platform: process.platform as Platform,
notifications: { notifications: {
requestPermission: () => ipcRenderer.invoke('request-notification-permission'), requestPermission: () => ipcRenderer.invoke('request-notification-permission'),
show: (options: any) => ipcRenderer.invoke('show-notification', options) show: (options) => ipcRenderer.invoke('show-notification', options)
} }
} } satisfies ElectronInterface);
contextBridge.exposeInMainWorld("electronInterface", electronInterface);
+92 -2
View File
@@ -5,7 +5,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/s
import { randomBytes } from "../utils/crypto/kdf"; import { randomBytes } from "../utils/crypto/kdf";
import { getCurrentKeys } from "../auth/crypto"; import { getCurrentKeys } from "../auth/crypto";
import { request } from "../core/websocket"; 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"; import { b64, ub64 } from "../utils/utils";
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> { export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
@@ -46,7 +46,7 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe
return data.messages || []; return data.messages || [];
} }
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string): Promise<void> { export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const keys = getCurrentKeys(); const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized"); if (!keys) throw new Error("Keys not initialized");
@@ -69,6 +69,7 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey
iv2: b64(wrap.iv), iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext) wrappedMk: b64(wrap.ciphertext)
}; };
if (replyToId) payload.replyToId = replyToId;
await request({ await request({
type: "dmSend", type: "dmSend",
@@ -79,3 +80,92 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey
data: payload data: payload
}); });
} }
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
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<void> {
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<void> {
await request({
type: "dmDelete",
credentials: { scheme: "Bearer", credentials: authToken },
data: { id, recipientId }
});
}
+3 -1
View File
@@ -6,5 +6,7 @@
*/ */
import { PRODUCT_NAME } from "./config"; import { PRODUCT_NAME } from "./config";
import { enableMapSet } from "immer";
document.title = PRODUCT_NAME; document.title = PRODUCT_NAME;
enableMapSet();
+136 -6
View File
@@ -60,6 +60,11 @@ export interface Message {
timestamp: string; timestamp: string;
profile_picture?: string; profile_picture?: string;
reply_to?: Message; reply_to?: Message;
files?: Attachment[];
runtimeData?: {
dmEnvelope?: DmEnvelope;
}
} }
/** /**
@@ -154,6 +159,7 @@ export interface SendDMRequest {
salt: string; salt: string;
iv2: string; iv2: string;
wrappedMk: string; wrappedMk: string;
replyToId?: number;
} }
// Responses // Responses
@@ -173,22 +179,54 @@ export interface BackupBlob {
blob: string; blob: string;
} }
export interface DmEnvelope { export interface BaseDmEnvelope {
id: number;
senderId: number;
recipientId: number;
iv: string; iv: string;
ciphertext: string; ciphertext: string;
salt: string; salt: string;
iv2: string; iv2: string;
wrappedMk: string; wrappedMk: string;
recipientId: number;
}
export interface DmEnvelope extends BaseDmEnvelope {
id: number;
senderId: number;
files?: DmFile[];
timestamp: string; 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 { export interface FetchDMResponse {
messages: DmEnvelope[] messages: DmEnvelope[]
} }
export interface DmEncryptedJSON {
type: "text",
data: {
content: string;
reply_to_id?: number;
files?: Attachment[];
}
}
// --------------- // ---------------
// WebSocket types // WebSocket types
// --------------- // ---------------
@@ -201,10 +239,10 @@ export interface FetchDMResponse {
* @property {any} [data] - Message payload data * @property {any} [data] - Message payload data
* @property {WebSocketError} [error] - Error information if applicable * @property {WebSocketError} [error] - Error information if applicable
*/ */
export interface WebSocketMessage { export interface WebSocketMessage<T> {
type: string; type: string;
credentials?: WebSocketCredentials; credentials?: WebSocketCredentials;
data?: any; data?: T;
error?: WebSocketError; error?: WebSocketError;
} }
@@ -230,6 +268,98 @@ export interface WebSocketCredentials {
credentials: string; 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<Message> & { 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 // React types
// ----------- // -----------
+4 -4
View File
@@ -33,17 +33,17 @@ export let websocket: WebSocket = create();
* Global WebSocket message handler reference * Global WebSocket message handler reference
* This will be set by the active panel to handle incoming messages * This will be set by the active panel to handle incoming messages
*/ */
let globalMessageHandler: ((response: WebSocketMessage) => void) | null = null; let globalMessageHandler: ((response: WebSocketMessage<any>) => void) | null = null;
/** /**
* Set the global WebSocket message handler * Set the global WebSocket message handler
* @param handler - Function to handle WebSocket messages * @param handler - Function to handle WebSocket messages
*/ */
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage) => void) | null): void { export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<any>) => void) | null): void {
globalMessageHandler = handler; globalMessageHandler = handler;
} }
export function request(payload: WebSocketMessage): Promise<WebSocketMessage> { export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
console.log("WebSocket request:", payload); console.log("WebSocket request:", payload);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
function requestInner() { function requestInner() {
@@ -95,7 +95,7 @@ async function onError() {
websocket.addEventListener("message", (e) => { websocket.addEventListener("message", (e) => {
try { try {
const response: WebSocketMessage = JSON.parse(e.data); const response: WebSocketMessage<any> = JSON.parse(e.data);
// Route message to global handler if set // Route message to global handler if set
if (globalMessageHandler) { if (globalMessageHandler) {
+214 -47
View File
@@ -65,6 +65,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
position: relative;
.chat-header { .chat-header {
padding: 16px; padding: 16px;
@@ -172,28 +173,7 @@
align-items: flex-end; align-items: flex-end;
gap: 8px; 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 { .message-inner {
padding: 0.8rem 1rem;
border-radius: 12px; border-radius: 12px;
position: relative; position: relative;
word-wrap: break-word; word-wrap: break-word;
@@ -203,9 +183,43 @@
max-width: 100%; max-width: 100%;
display: inline-block; 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 { .message-content {
word-wrap: break-word; word-wrap: break-word;
margin-bottom: 10px; margin: 10px 10px 0 10px;
white-space: pre-wrap; white-space: pre-wrap;
> p:first-child { > p:first-child {
@@ -219,7 +233,70 @@
.quote.reply-preview { .quote.reply-preview {
user-select: none; 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 { .message-time {
@@ -228,6 +305,7 @@
margin-top: 0.3rem; margin-top: 0.3rem;
text-align: right; text-align: right;
user-select: none; 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 { .file-overlay {
color: $color-dark-primary; position: absolute;
text-decoration: underline; 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 { .chat-input {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -331,22 +447,29 @@
width: 100%; width: 100%;
} }
.send-btn { .buttons {
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; align-self: flex-end;
display: flex;
@include hoverStateLayer($background: $color-dark-primary); 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; 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;
}
} }
@@ -1,11 +1,13 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { MaterialDialog } from "../core/Dialog";
import { RichTextArea } from "../core/RichTextArea"; import { RichTextArea } from "../core/RichTextArea";
import type { Message } from "../../../core/types"; import type { Message } from "../../../core/types";
import Quote from "../core/Quote"; import Quote from "../core/Quote";
import AnimatedHeight from "../core/animations/AnimatedHeight"; import AnimatedHeight from "../core/animations/AnimatedHeight";
import { useImmer } from "use-immer";
interface ChatInputWrapperProps { interface ChatInputWrapperProps {
onSendMessage: (message: string) => void; onSendMessage: (message: string, files: File[]) => void;
onSaveEdit?: (content: string) => void; onSaveEdit?: (content: string) => void;
replyTo?: Message | null; replyTo?: Message | null;
replyToVisible: boolean; replyToVisible: boolean;
@@ -15,35 +17,83 @@ interface ChatInputWrapperProps {
editVisible?: boolean; editVisible?: boolean;
onClearEdit?: () => void; onClearEdit?: () => void;
onCloseEdit?: () => 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 [message, setMessage] = useState("");
const [selectedFiles, setSelectedFiles] = useImmer<File[]>([]);
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 // When entering edit mode, preload the message content
useEffect(() => { useEffect(() => {
if (editingMessage) { setMessage(editingMessage ? editingMessage.content || "" : "");
setMessage(editingMessage.content || "");
} else {
setMessage("");
}
}, [editingMessage]); }, [editingMessage]);
useEffect(() => {
setAttachmentsVisible(selectedFiles.length > 0);
}, [selectedFiles]);
const handleSubmit = async (e: React.FormEvent | Event) => { const handleSubmit = async (e: React.FormEvent | Event) => {
e.preventDefault(); 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) { if (editingMessage && onSaveEdit) {
onSaveEdit(message); onSaveEdit(message);
setMessage(""); setMessage("");
if (onClearEdit) onClearEdit(); if (onClearEdit) onClearEdit();
} else { } else {
onSendMessage(message); onSendMessage(message, selectedFiles);
setMessage(""); setMessage("");
setAttachmentsVisible(false);
if (onClearReply) onClearReply(); 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 ( return (
<div className="chat-input-wrapper"> <div className="chat-input-wrapper">
<form className="input-group" id="message-form" onSubmit={handleSubmit}> <form className="input-group" id="message-form" onSubmit={handleSubmit}>
@@ -71,6 +121,34 @@ export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVi
</div> </div>
)} )}
</AnimatedHeight> </AnimatedHeight>
<AnimatedHeight visible={attachmentsVisible} onFinish={() => setSelectedFiles([])}>
{selectedFiles.length > 0 && (
<div className="attachments-preview contextual-preview">
<mdui-icon name="attach_file" />
<div className="attachments-chips">
{selectedFiles.map((file, i) => (
<mdui-chip
key={i}
variant="input"
end-icon="close"
title={`${file.name} (${Math.round(file.size/1024/1024)} MB)`}
onClick={() => {
if (selectedFiles.length == 1) {
setAttachmentsVisible(false);
} else {
setSelectedFiles(draft => { draft.splice(i) })
}
}}
>
<mdui-icon slot="icon" name="attach_file"></mdui-icon>
<span className="name">{file.name}</span>
</mdui-chip>
))}
</div>
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></mdui-button-icon>
</div>
)}
</AnimatedHeight>
<div className="chat-input"> <div className="chat-input">
<RichTextArea <RichTextArea
className="message-input" className="message-input"
@@ -81,11 +159,19 @@ export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVi
rows={1} rows={1}
onTextChange={(value) => setMessage(value)} onTextChange={(value) => setMessage(value)}
onEnter={handleSubmit} /> onEnter={handleSubmit} />
<button type="submit" className="send-btn"> <div className="buttons">
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span> <mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
</button> <button type="submit" className="send-btn">
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
</button>
</div>
</div> </div>
</form> </form>
<MaterialDialog open={errorOpen} onOpenChange={setErrorOpen} close-on-overlay-click close-on-esc>
<div slot="headline">Ошибка</div>
<div>Общий размер вложений превышает 4 ГБ.</div>
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
</MaterialDialog>
</div> </div>
); );
} }
@@ -8,7 +8,6 @@ import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"
import { fetchUserProfile } from "../../../api/profileApi"; import { fetchUserProfile } from "../../../api/profileApi";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { delay } from "../../../utils/utils"; import { delay } from "../../../utils/utils";
import { request } from "../../../core/websocket";
import { MaterialDialog } from "../core/Dialog"; import { MaterialDialog } from "../core/Dialog";
interface ChatMessagesProps { interface ChatMessagesProps {
@@ -17,9 +16,11 @@ interface ChatMessagesProps {
children?: ReactNode; children?: ReactNode;
onReplySelect?: (message: MessageType) => void; onReplySelect?: (message: MessageType) => void;
onEditSelect?: (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 { messages: hookMessages } = useChat();
const { user } = useAppState(); const { user } = useAppState();
@@ -38,7 +39,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
// Delete dialog // Delete dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [toBeDeleted, setToBeDeleted] = useState<number | null>(null); const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
useEffect(() => { useEffect(() => {
if (!deleteDialogOpen) { if (!deleteDialogOpen) {
@@ -88,28 +89,31 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
}; };
async function confirmDelete() { async function confirmDelete() {
if (toBeDeleted) { if (!toBeDeleted || !user.authToken) return;
if (!user.authToken) return; try {
onDelete?.(toBeDeleted.id);
try { // if (toBeDeleted.isDm) {
await request({ // // For DM, send dmDelete
type: "deleteMessage", // await request({
data: { message_id: toBeDeleted }, // type: "dmDelete",
credentials: { // data: { id: toBeDeleted.id },
scheme: "Bearer", // credentials: { scheme: "Bearer", credentials: user.authToken }
credentials: user.authToken // });
} // } else {
}); // await request({
} catch (error) { // type: "deleteMessage",
console.error("Failed to delete message:", error); // data: { message_id: toBeDeleted.id },
} // credentials: { scheme: "Bearer", credentials: user.authToken }
// });
setDeleteDialogOpen(false); // }
} catch (error) {
console.error("Failed to delete message:", error);
} }
setDeleteDialogOpen(false);
} }
async function handleDelete(message: MessageType) { async function handleDelete(message: MessageType) {
setToBeDeleted(message.id); setToBeDeleted({ id: message.id, isDm });
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
} }
@@ -124,7 +128,8 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
onProfileClick={handleProfileClick} onProfileClick={handleProfileClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
isLoadingProfile={isLoadingProfile} isLoadingProfile={isLoadingProfile}
isDm={isDm} /> isDm={isDm}
dmRecipientPublicKey={dmRecipientPublicKey} />
))} ))}
{children} {children}
</div> </div>
-128
View File
@@ -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<HTMLDivElement>(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 (
<div className="chat-main" id="chat-inner">
<div className="chat-header">
<img src={defaultAvatar} alt="Avatar" className="chat-header-avatar" />
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">Выберите пользователя</h4>
<p>
<span className="online-status"></span>
Выберите пользователя для начала разговора
</p>
</div>
</div>
</div>
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Выберите пользователя из списка для начала личных сообщений
</div>
</div>
</div>
);
}
return (
<div className="chat-main" id="chat-inner">
<div className="chat-header">
<img
src={defaultAvatar}
alt="Avatar"
className="chat-header-avatar"
onClick={handleProfileClick}
style={{ cursor: "pointer" }}
/>
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{activeDm.username}</h4>
<p>
<span className="online-status"></span>
Личные сообщения
</p>
</div>
<a href="#" id="hide-chat">Свернуть чат</a>
</div>
</div>
<div className="chat-messages" id="chat-messages">
{isLoadingHistory ? (
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Загрузка сообщений...
</div>
) : (
<>
<ChatMessages />
<div ref={messagesEndRef} />
</>
)}
</div>
<div className="chat-input-wrapper">
<div className="chat-input">
<form className="input-group" id="message-form" onSubmit={handleSendMessage}>
<input
type="text"
className="message-input"
id="message-input"
placeholder="Напишите сообщение..."
autoComplete="off"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button type="submit" className="send-btn">
<span className="material-symbols filled">send</span>
</button>
</form>
</div>
</div>
</div>
);
}
@@ -38,10 +38,7 @@ export function DMUsersList() {
if (!user.publicKey) { if (!user.publicKey) {
// Get public key if not already loaded // Get public key if not already loaded
const authToken = useAppState.getState().user.authToken; const authToken = useAppState.getState().user.authToken;
if (!authToken) { if (!authToken) return;
console.error("No auth token available");
return;
}
const publicKey = await fetchUserPublicKey(user.id, authToken); const publicKey = await fetchUserPublicKey(user.id, authToken);
if (publicKey) { if (publicKey) {
+380 -53
View File
@@ -1,10 +1,17 @@
import { formatTime } from "../../../utils/utils"; 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 defaultAvatar from "../../../resources/images/default-avatar.png";
import Quote from "../core/Quote"; import Quote from "../core/Quote";
import { parse } from "marked"; import { parse } from "marked";
import DOMPurify from "dompurify"; 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 { interface MessageProps {
message: MessageType; message: MessageType;
@@ -13,10 +20,33 @@ interface MessageProps {
onContextMenu: (e: React.MouseEvent, message: MessageType) => void; onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
isLoadingProfile?: boolean; isLoadingProfile?: boolean;
isDm?: boolean; isDm?: boolean;
dmRecipientPublicKey?: string;
} }
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false }: MessageProps) { interface Rect {
const [formattedMessage, setFormattedMessage] = useState({ __html: DOMPurify.sanitize(message.content).trim() }); 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<Map<string, string>>(new Map());
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
const [downloadingPaths, updateDownloadingPaths] = useImmer<Set<string>>(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<Map<string, HTMLImageElement>>(new Map());
const dmEnvelope = message.runtimeData?.dmEnvelope;
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -28,6 +58,220 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
})(); })();
}, [message]); }, [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<string | null> => {
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) { function handleContextMenu(e: React.MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -35,57 +279,140 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
} }
return ( return (
<div <>
className={`message ${isAuthor ? "sent" : "received"}`} <div
data-id={message.id} className={`message ${isAuthor ? "sent" : "received"}`}
onContextMenu={handleContextMenu} data-id={message.id}
> onContextMenu={handleContextMenu}
<div className="message-inner"> >
{/* Add profile picture for received messages */} <div className="message-inner">
{!isAuthor && !isDm && ( {/* Add profile picture for received messages */}
<div className="message-profile-pic"> {!isAuthor && !isDm && (
<img <div className="message-profile-pic">
src={message.profile_picture || defaultAvatar} <img
alt={message.username} src={message.profile_picture || defaultAvatar}
onClick={() => !isLoadingProfile && onProfileClick(message.username)} alt={message.username}
style={{ cursor: isLoadingProfile ? "default" : "pointer" }} onClick={() => !isLoadingProfile && onProfileClick(message.username)}
className={isLoadingProfile ? "loading" : ""} style={{ cursor: isLoadingProfile ? "default" : "pointer" }}
onError={(e) => { className={isLoadingProfile ? "loading" : ""}
const target = e.target as HTMLImageElement; onError={(e) => {
target.src = defaultAvatar; const target = e.target as HTMLImageElement;
}} target.src = defaultAvatar;
/> }}
</div> />
)} </div>
{!isAuthor && !isDm && (
<div
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
{message.username}
</div>
)}
{/* Add reply preview if this is a reply */}
{message.reply_to && (
<Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
<span className="reply-username">{message.reply_to.username}</span>
<span className="reply-text">{message.reply_to.content}</span>
</Quote>
)}
<div className="message-content" dangerouslySetInnerHTML={formattedMessage} />
<div className="message-time">
{formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined}
{isAuthor && message.is_read && (
<span className="material-symbols outlined"></span>
)} )}
{!isAuthor && !isDm && (
<div
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
{message.username}
</div>
)}
{/* Add reply preview if this is a reply */}
{message.reply_to && (
<Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
<span className="reply-username">{message.reply_to.username}</span>
<span className="reply-text">{message.reply_to.content}</span>
</Quote>
)}
<div className="message-content" dangerouslySetInnerHTML={formattedMessage} />
{message.files && message.files.length > 0 && (
<mdui-list className="message-attachments">
{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 (
<div className="attachment" key={idx}>
{isImage ? (
<div className="image-wrapper">
<img
ref={(el) => {
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) && (
<div className="loading-overlay">
<mdui-circular-progress />
</div>
)}
</div>
) : (
<a
href="#"
onClick={async (e) => {
e.preventDefault();
await downloadFile(file);
}}
>
<mdui-list-item>
<span className="with-icon-gap">
{isDownloading ? <mdui-circular-progress /> : null}
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
</span>
</mdui-list-item>
</a>
)}
</div>
);
})}
</mdui-list>
)}
<div className="message-time">
{formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined}
{isAuthor && message.is_read && (
<span className="material-symbols outlined"></span>
)}
</div>
</div> </div>
</div> </div>
</div>
{/* Fullscreen Image Viewer with shared-element like transition */}
{fullscreenImage && (
<div
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
onClick={closeFullscreen}>
<img
src={fullscreenImage.src}
alt={fullscreenImage.name}
className={`fullscreen-animated-image ${isAnimatingOpen ? "to-end" : "to-start"}`}
style={{
left: `${isAnimatingOpen ? fullscreenImage.endRect.left : fullscreenImage.startRect.left}px`,
top: `${isAnimatingOpen ? fullscreenImage.endRect.top : fullscreenImage.startRect.top}px`,
width: `${isAnimatingOpen ? fullscreenImage.endRect.width : fullscreenImage.startRect.width}px`,
height: `${isAnimatingOpen ? fullscreenImage.endRect.height : fullscreenImage.startRect.height}px`
}}
onClick={e => e.stopPropagation()}
/>
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
<mdui-button-icon icon="close" onClick={closeFullscreen} />
{isDownloadingFullscreen ? (
<div className="progress-wrapper">
<mdui-circular-progress />
</div>
) : (
<mdui-button-icon icon="download" onClick={downloadImage} />
)}
</div>
</div>
)}
</>
); );
} }
@@ -5,6 +5,8 @@ import { ChatInputWrapper } from "./ChatInputWrapper";
import { setGlobalMessageHandler } from "../../../core/websocket"; import { setGlobalMessageHandler } from "../../../core/websocket";
import type { Message } from "../../../core/types"; import type { Message } from "../../../core/types";
import defaultAvatar from "../../../resources/images/default-avatar.png"; import defaultAvatar from "../../../resources/images/default-avatar.png";
import AnimatedOpacity from "../core/animations/AnimatedOpacity";
import type { DMPanel } from "../../panels/DMPanel";
interface MessagePanelRendererProps { interface MessagePanelRendererProps {
panel: MessagePanel | null; panel: MessagePanel | null;
@@ -22,6 +24,20 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
const [editVisible, setEditVisible] = useState(Boolean(editMessage)); const [editVisible, setEditVisible] = useState(Boolean(editMessage));
const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null); const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null);
// Drag & drop
const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0);
const addFilesRef = useRef<null | ((files: File[]) => void)>(null);
useEffect(() => {
if (!panel || !panelState) return;
return () => {
dragCounterRef.current = 0;
setIsDragging(false);
};
}, [panel, panelState]);
useEffect(() => { useEffect(() => {
if (replyTo) { if (replyTo) {
setReplyToVisible(true); setReplyToVisible(true);
@@ -116,7 +132,41 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
return ( return (
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}> <div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
<div className="chat-main" id="chat-inner"> <div
className="chat-main"
id="chat-inner"
onDragEnter={(e) => {
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;
}}>
<div className="chat-header"> <div className="chat-header">
<img <img
src={panelState.profilePicture || defaultAvatar} src={panelState.profilePicture || defaultAvatar}
@@ -153,6 +203,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
<ChatMessages <ChatMessages
messages={panelState.messages} messages={panelState.messages}
isDm={panel.isDm()} isDm={panel.isDm()}
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
onReplySelect={(message) => { onReplySelect={(message) => {
if (editMessage || editVisible) { if (editMessage || editVisible) {
setPendingAction({ type: "reply", message: message }); setPendingAction({ type: "reply", message: message });
@@ -169,14 +220,28 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
setEditMessage(message); setEditMessage(message);
} }
}} }}
onDelete={(id) => panel.handleDeleteMessage(id)}
> >
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</ChatMessages> </ChatMessages>
)} )}
<AnimatedOpacity
visible={isDragging}
className="file-overlay"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}>
<div className="file-overlay-wrapper">
<div className="file-overlay-inner">
<mdui-icon name="upload_file" />
<span>Отпустите файл(ы) для добавления</span>
</div>
</div>
</AnimatedOpacity>
<ChatInputWrapper <ChatInputWrapper
onSendMessage={(text) => { onSendMessage={(text, files) => {
panel.handleSendMessage(text, replyTo?.id); panel.handleSendMessage(text, replyTo?.id, files);
setReplyTo(null); setReplyTo(null);
}} }}
onSaveEdit={(content) => { onSaveEdit={(content) => {
@@ -211,6 +276,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
setPendingAction(null); setPendingAction(null);
} }
}} }}
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
/> />
</div> </div>
</div> </div>
@@ -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 { export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children, ...props }: AnimatedPropertyProps) {
visible: any;
duration?: number;
onFinish?: () => void
children?: ReactNode;
}
export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children }: AnimatedHeightProps) {
const [height, setHeight] = useState("0px"); const [height, setHeight] = useState("0px");
const [shouldRender, setShouldRender] = useState(visible); const [shouldRender, setShouldRender] = useState(!!visible);
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null); const measureRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
@@ -26,44 +20,47 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
} }
// Animation complete // Animation complete
setTimeout(() => { setTimeout(() => {
setHeight("auto");
setIsAnimating(false); setIsAnimating(false);
}, duration * 1000); }, duration * 1000);
}, 0); }, 0);
} else { } else if (shouldRender) {
if (shouldRender) { setIsAnimating(true);
setIsAnimating(true); if (measureRef.current) {
if (measureRef.current) { const contentHeight = measureRef.current.scrollHeight;
const contentHeight = measureRef.current.scrollHeight; setHeight(`${contentHeight}px`);
setHeight(`${contentHeight}px`); // Force a reflow before animating to 0
// 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(() => { requestAnimationFrame(() => {
setHeight("0px"); 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 return (visible || shouldRender || isAnimating) && (
if (!visible && !shouldRender && !isAnimating) { <div
return null; {...props}
} ref={containerRef}
return (
<div
ref={contentRef}
style={{ style={{
height, height,
transition: `height ${duration}s ease`, transition: `height ${duration}s ease`,
overflow: "hidden" overflow: "hidden",
...props.style
}} }}
> >
<div ref={measureRef} style={{ height: "auto" }}> <div ref={measureRef} style={{ height: "auto" }}>
@@ -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 && (
<div
{...props}
style={{
opacity,
transition: `opacity ${duration}s ease`,
...props.style
}}
>{children}</div>
);
}
+10
View File
@@ -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">
+4 -46
View File
@@ -7,7 +7,7 @@ import {
decryptDm, decryptDm,
sendDMViaWebSocket sendDMViaWebSocket
} from "../../api/dmApi"; } from "../../api/dmApi";
import type { User, Message } from "../../core/types"; import type { User, Message, DmEncryptedJSON } from "../../core/types";
import { websocket } from "../../core/websocket"; import { websocket } from "../../core/websocket";
interface DMUser extends User { interface DMUser extends User {
@@ -41,7 +41,8 @@ export function useDM() {
let lastPlaintext: string | null = null; let lastPlaintext: string | null = null;
try { try {
lastPlaintext = await decryptDm(lastMessage, publicKey); lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
console.log(lastPlaintext);
} catch (error) { } catch (error) {
console.error("Failed to decrypt last message:", 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 // Load last messages and unread counts for visible users
// Call loadUserLastMessage directly without dependency // Call loadUserLastMessage directly without dependency
for (const dmUser of dmUsersWithState) { for (const dmUser of dmUsersWithState) {
if (!user.authToken) continue; await loadUserLastMessage(dmUser);
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);
}
} }
} catch (error) { } catch (error) {
console.error("Failed to load DM users:", error); console.error("Failed to load DM users:", error);
+143 -42
View File
@@ -1,10 +1,13 @@
import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from "./MessagePanel"; import { MessagePanel } from "./MessagePanel";
import { import {
fetchDMHistory, fetchDMHistory,
decryptDm, decryptDm,
sendDMViaWebSocket sendDMViaWebSocket,
sendDmWithFiles,
editDmEnvelope,
deleteDmEnvelope
} from "../../api/dmApi"; } 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"; import type { UserState } from "../state";
export interface DMPanelData { export interface DMPanelData {
@@ -16,15 +19,13 @@ export interface DMPanelData {
} }
export class DMPanel extends MessagePanel { export class DMPanel extends MessagePanel {
private dmData: DMPanelData | null = null; public dmData: DMPanelData | null = null;
private messagesLoaded: boolean = false; private messagesLoaded: boolean = false;
constructor( constructor(
user: UserState, user: UserState
callbacks: MessagePanelCallbacks,
onStateChange: (state: MessagePanelState) => void
) { ) {
super("dm", user, callbacks, onStateChange); super("dm", user);
} }
isDm(): boolean { isDm(): boolean {
@@ -41,6 +42,44 @@ export class DMPanel extends MessagePanel {
// DM doesn't need special cleanup // 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<void> { async loadMessages(): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return; if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return;
@@ -52,18 +91,8 @@ export class DMPanel extends MessagePanel {
for (const env of messages) { for (const env of messages) {
try { try {
const text = await decryptDm(env, this.dmData!.publicKey); const dmMsg = await this.parseTextPayload(env, decryptedMessages);
const isAuthor = env.senderId !== this.dmData!.userId; decryptedMessages.push(dmMsg);
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
});
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) { if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
maxIncomingId = env.id; maxIncomingId = env.id;
@@ -88,16 +117,35 @@ export class DMPanel extends MessagePanel {
} }
} }
async sendMessage(content: string, _replyToId?: number): Promise<void> { async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return; if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
try { try {
await sendDMViaWebSocket( const payload: DmEncryptedJSON = {
this.dmData.userId, type: "text",
this.dmData.publicKey, data: {
content, content: content.trim(),
this.currentUser.authToken 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) { } catch (error) {
console.error("Failed to send DM:", error); console.error("Failed to send DM:", error);
} }
@@ -116,27 +164,18 @@ export class DMPanel extends MessagePanel {
} }
// Handle incoming WebSocket DM messages // Handle incoming WebSocket DM messages
handleWebSocketMessage = async (response: WebSocketMessage): Promise<void> => { handleWebSocketMessage = async (response: DMWebSocketMessage): Promise<void> => {
if (response.type === "dmNew" && this.dmData) { 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 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 { try {
const plaintext = await decryptDm(envelope, this.dmData.publicKey); const dmMsg = await this.parseTextPayload(envelope, this.getMessages());
const isAuthor = senderId !== this.dmData.userId; this.addMessage(dmMsg);
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
});
// Update last read if it's from the other user // 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)); this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
} }
} catch (error) { } 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<Message> = { 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 // Reset for DM switching
@@ -179,4 +255,29 @@ export class DMPanel extends MessagePanel {
localStorage.setItem(`dmLastRead:${userId}`, String(id)); localStorage.setItem(`dmLastRead:${userId}`, String(id));
} catch {} } catch {}
} }
async handleDeleteMessage(messageId: number): Promise<void> {
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<void> {
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 {}
} }
+9 -27
View File
@@ -12,7 +12,7 @@ export interface MessagePanelState {
} }
export interface MessagePanelCallbacks { export interface MessagePanelCallbacks {
onSendMessage: (content: string) => void; onSendMessage: (content: string, files: File[]) => void;
onEditMessage: (messageId: number, content: string) => void; onEditMessage: (messageId: number, content: string) => void;
onDeleteMessage: (messageId: number) => void; onDeleteMessage: (messageId: number) => void;
onReplyToMessage: (messageId: number, content: string) => void; onReplyToMessage: (messageId: number, content: string) => void;
@@ -21,15 +21,12 @@ export interface MessagePanelCallbacks {
export abstract class MessagePanel { export abstract class MessagePanel {
protected state: MessagePanelState; protected state: MessagePanelState;
protected callbacks: MessagePanelCallbacks; public onStateChange: ((state: MessagePanelState) => void) | null = () => {};
public onStateChange: ((state: MessagePanelState) => void) | null; protected readonly currentUser: UserState;
protected currentUser: UserState;
constructor( constructor(
id: string, id: string,
currentUser: UserState, currentUser: UserState,
callbacks: MessagePanelCallbacks,
onStateChange: (state: MessagePanelState) => void
) { ) {
this.state = { this.state = {
id, id,
@@ -40,15 +37,13 @@ export abstract class MessagePanel {
isTyping: false isTyping: false
}; };
this.currentUser = currentUser; this.currentUser = currentUser;
this.callbacks = callbacks;
this.onStateChange = onStateChange;
} }
// Abstract methods that must be implemented by subclasses // Abstract methods that must be implemented by subclasses
abstract activate(): Promise<void>; abstract activate(): Promise<void>;
abstract deactivate(): void; abstract deactivate(): void;
abstract loadMessages(): Promise<void>; abstract loadMessages(): Promise<void>;
abstract sendMessage(content: string, replyToId?: number): Promise<void>; abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
abstract isDm(): boolean; abstract isDm(): boolean;
// Optional WebSocket message handler (can be overridden by subclasses) // Optional WebSocket message handler (can be overridden by subclasses)
@@ -115,23 +110,10 @@ export abstract class MessagePanel {
} }
// Event handlers // Event handlers
handleSendMessage = (content: string, replyToId?: number): void => { handleSendMessage(content: string, replyToId?: number, files: File[] = []): void {
this.sendMessage(content, replyToId); this.sendMessage(content, replyToId, files);
};
handleEditMessage = (messageId: number, content: string): void => {
this.callbacks.onEditMessage(messageId, content);
};
handleDeleteMessage = (messageId: number): void => {
this.callbacks.onDeleteMessage(messageId);
};
handleReplyToMessage = (messageId: number, content: string): void => {
this.callbacks.onReplyToMessage(messageId, content);
};
handleProfileClick = (): void => {
this.callbacks.onProfileClick();
}; };
abstract handleEditMessage(messageId: number, content: string): Promise<void>;
abstract handleDeleteMessage(messageId: number): Promise<void>;
abstract handleProfileClick(): void;
} }
+68 -22
View File
@@ -1,8 +1,8 @@
import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from "./MessagePanel"; import { MessagePanel } from "./MessagePanel";
import { API_BASE_URL } from "../../core/config"; import { API_BASE_URL } from "../../core/config";
import { getAuthHeaders } from "../../auth/api"; import { getAuthHeaders } from "../../auth/api";
import { request } from "../../core/websocket"; 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"; import type { UserState } from "../state";
export class PublicChatPanel extends MessagePanel { export class PublicChatPanel extends MessagePanel {
@@ -10,11 +10,9 @@ export class PublicChatPanel extends MessagePanel {
constructor( constructor(
chatName: string, chatName: string,
currentUser: UserState, currentUser: UserState
callbacks: MessagePanelCallbacks,
onStateChange: (state: MessagePanelState) => void
) { ) {
super(`public-${chatName}`, currentUser, callbacks, onStateChange); super(`public-${chatName}`, currentUser);
this.updateState({ this.updateState({
title: chatName, title: chatName,
online: true // Public chats are always "online" online: true // Public chats are always "online"
@@ -61,24 +59,40 @@ export class PublicChatPanel extends MessagePanel {
} }
} }
async sendMessage(content: string, replyToId?: number): Promise<void> { async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !content.trim()) return; if (!this.currentUser.authToken || !content.trim()) return;
try { try {
const response = await request({ if (files.length === 0) {
data: { const response = await request({
content: content.trim(), data: {
reply_to_id: replyToId ?? null content: content.trim(),
}, reply_to_id: replyToId ?? null
credentials: { },
scheme: "Bearer", credentials: {
credentials: this.currentUser.authToken scheme: "Bearer",
}, credentials: this.currentUser.authToken
type: "sendMessage" },
}); type: "sendMessage"
} satisfies SendMessageRequest);
if (response.error) { if (response.error) {
console.error("Error sending message:", 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) { } catch (error) {
console.error("Error sending message:", error); console.error("Error sending message:", error);
@@ -86,7 +100,7 @@ export class PublicChatPanel extends MessagePanel {
} }
// Handle incoming WebSocket messages // Handle incoming WebSocket messages
handleWebSocketMessage = (response: WebSocketMessage): void => { handleWebSocketMessage = (response: ChatWebSocketMessage): void => {
switch (response.type) { switch (response.type) {
case 'messageEdited': case 'messageEdited':
if (response.data) { if (response.data) {
@@ -124,4 +138,36 @@ export class PublicChatPanel extends MessagePanel {
setAuthToken(authToken: string): void { setAuthToken(authToken: string): void {
this.currentUser.authToken = authToken; this.currentUser.authToken = authToken;
} }
async handleEditMessage(messageId: number, content: string): Promise<void> {
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<void> {
await request({
type: "deleteMessage",
data: { message_id: id },
credentials: {
scheme: "Bearer",
credentials: this.currentUser.authToken!
}
});
}
handleProfileClick(): void {}
} }
+2 -44
View File
@@ -277,37 +277,7 @@ export const useAppState = create<AppState>((set, get) => ({
// Create or get public chat panel // Create or get public chat panel
let publicChatPanel = chat.publicChatPanel; let publicChatPanel = chat.publicChatPanel;
if (!publicChatPanel) { if (!publicChatPanel) {
const callbacks = { publicChatPanel = new PublicChatPanel(chatName, user);
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
);
} else { } else {
publicChatPanel.setChatName(chatName); publicChatPanel.setChatName(chatName);
publicChatPanel.setAuthToken(user.authToken); publicChatPanel.setAuthToken(user.authToken);
@@ -346,19 +316,7 @@ export const useAppState = create<AppState>((set, get) => ({
// Create or get DM panel // Create or get DM panel
let dmPanel = chat.dmPanel; let dmPanel = chat.dmPanel;
if (!dmPanel) { if (!dmPanel) {
const callbacks = { dmPanel = new DMPanel(user);
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
);
} else { } else {
dmPanel.setAuthToken(user.authToken); dmPanel.setAuthToken(user.authToken);
} }
+1
View File
@@ -20,6 +20,7 @@ import 'mdui/components/button-icon';
import 'mdui/components/top-app-bar'; import 'mdui/components/top-app-bar';
import 'mdui/components/top-app-bar-title'; import 'mdui/components/top-app-bar-title';
import 'mdui/components/switch'; import 'mdui/components/switch';
import 'mdui/components/chip';
import { setColorScheme } from 'mdui/functions/setColorScheme.js'; import { setColorScheme } from 'mdui/functions/setColorScheme.js';
+5 -4
View File
@@ -1,7 +1,7 @@
import { API_BASE_URL } from "../core/config"; import { API_BASE_URL } from "../core/config";
import { isElectron } from "../electron/electron"; import { isElectron } from "../electron/electron";
import { websocket } from "../core/websocket"; import { websocket } from "../core/websocket";
import type { WebSocketMessage } from "../core/types"; import type { NewMessageWebSocketMessage, WebSocketMessage } from "../core/types";
export interface PushSubscriptionData { export interface PushSubscriptionData {
endpoint: string; endpoint: string;
@@ -124,10 +124,11 @@ async function showMessageNotification(message: any): Promise<void> {
} }
} }
async function handleWebSocketMessage(response: WebSocketMessage): Promise<void> { async function handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void> {
// Handle notifications for new messages // Handle notifications for new messages
if (response.type === "newMessage" && response.data) { 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<void> {
// Add our own message listener to the existing WebSocket // Add our own message listener to the existing WebSocket
messageListener = (event: MessageEvent) => { messageListener = (event: MessageEvent) => {
try { try {
const response: WebSocketMessage = JSON.parse(event.data); const response: WebSocketMessage<any> = JSON.parse(event.data);
handleWebSocketMessage(response); handleWebSocketMessage(response);
} catch (error) { } catch (error) {
console.error('Failed to parse WebSocket message:', error); console.error('Failed to parse WebSocket message:', error);
+1 -1
View File
@@ -11,7 +11,7 @@
"backend:run": "cd backend && dotenv -e ../deployment/.env -- ../.venv/bin/fastapi dev --port 8300 main.py", "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:dependencies": "python3 -m venv .venv && ./.venv/bin/pip3 install -r backend/requirements.txt",
"backend:reinstall": "rm -rf .venv && npm run backend:dependencies", "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:dev": "vite frontend",
"frontend:typecheck": "tsc --project frontend", "frontend:typecheck": "tsc --project frontend",
"frontend:build": "npm run frontend:typecheck && vite build frontend", "frontend:build": "npm run frontend:typecheck && vite build frontend",