mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Merge branch 'feature/upload_files'
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mdui": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@mdui/mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
Vendored
+8
-6
@@ -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 {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
Vendored
+136
-6
@@ -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
|
||||||
// -----------
|
// -----------
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user