Fix files, delete, edit in DMs

This commit is contained in:
2025-09-24 17:12:02 +03:00
Unverified
parent 6066ec9767
commit 1db2d55f76
16 changed files with 601 additions and 377 deletions
+16 -5
View File
@@ -1,5 +1,5 @@
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, text from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
from db import engine from db import engine
@@ -45,10 +45,6 @@ class MessageFile(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True) message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
path = Column(Text, nullable=False) path = Column(Text, nullable=False)
encrypted = Column(Boolean, default=False, nullable=False)
filename = Column(String(255), nullable=True)
content_type = Column(String(255), nullable=True)
size = Column(Integer, nullable=True)
message = relationship("Message", back_populates="files") message = relationship("Message", back_populates="files")
@@ -80,7 +76,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):
+121 -48
View File
@@ -4,14 +4,13 @@ from pathlib import Path
import os import os
import re import re
import uuid import uuid
from typing import Iterable
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form
from fastapi.responses import FileResponse 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, MessageFile 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 from PIL import Image
import io import io
@@ -150,11 +149,7 @@ async def send_message(
mf = MessageFile( mf = MessageFile(
message_id=new_message.id, message_id=new_message.id,
path=str(out_path), path=str(out_path)
encrypted=False,
filename=original_name,
content_type=up.content_type,
size=len(content),
) )
db.add(mf) db.add(mf)
db.commit() db.commit()
@@ -203,7 +198,6 @@ async def dm_send(
files: list[UploadFile] = File(default=[]), files: list[UploadFile] = File(default=[]),
fileNames: str | None = Form(default=None), # JSON array of filenames corresponding to files fileNames: str | None = Form(default=None), # JSON array of filenames corresponding to files
): ):
import json
if dm_payload and payload is None: if dm_payload and payload is None:
try: try:
payload = json.loads(dm_payload) payload = json.loads(dm_payload)
@@ -226,6 +220,7 @@ async def dm_send(
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()
@@ -235,12 +230,12 @@ async def dm_send(
if files: if files:
# Validate total size # Validate total size
total_size = 0 total_size = 0
for up in files: for file in files:
if hasattr(up, "size") and up.size is not None: if hasattr(file, "size") and file.size is not None:
total_size += int(up.size) total_size += int(file.size)
else: else:
data = await up.read() data = await file.read()
up.file.seek(0) file.file.seek(0)
total_size += len(data) total_size += len(data)
if total_size > MAX_TOTAL_SIZE: if total_size > MAX_TOTAL_SIZE:
raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB") raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB")
@@ -254,21 +249,31 @@ async def dm_send(
except Exception: except Exception:
names = [] names = []
for idx, up in enumerate(files): for i, file in enumerate(files):
provided = names[idx] if idx < len(names) else None provided = names[i] if i < len(names) else None
# Sanitize provided name to avoid path traversal # Sanitize provided name to avoid path traversal
if provided and not re.match(r"^[A-Za-z0-9._-]{1,200}$", provided): if provided and not re.match(r"^[A-Za-z0-9._-]{1,200}$", provided):
provided = None provided = None
original_name = provided or Path(up.filename or "file").name original_name = provided or Path(file.filename or "file").name
# Save using provided/original name to allow client to reference path directly # Save using provided/original name to allow client to reference path directly
safe_name = original_name safe_name = original_name
out_path = FILES_ENCRYPTED_DIR / safe_name out_name = f"{current_user.id}_{env.recipient_id}_{env.id}_{safe_name}"
out_path = FILES_ENCRYPTED_DIR / out_name
content = await up.read() content = await file.read()
with open(out_path, "wb") as f: with open(out_path, "wb") as f:
f.write(content) f.write(content)
# We do not store linkage to public messages for DMs; paths will be referenced inside encrypted JSON # Save DM file record
df = DMFile(
message_id=env.id,
sender_id=current_user.id,
recipient_id=env.recipient_id,
path=f"/api/uploads/files/encrypted/{out_name}",
name=safe_name
)
db.add(df)
db.commit()
# Send push notification for DM # Send push notification for DM
try: try:
@@ -278,7 +283,6 @@ async def dm_send(
# Realtime notify both users for HTTP requests # Realtime notify both users for HTTP requests
try: try:
from .messaging import messagingManager # self import
payload_ws = { payload_ws = {
"type": "dmNew", "type": "dmNew",
"data": { "data": {
@@ -291,6 +295,7 @@ async def dm_send(
"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,
} }
} }
await messagingManager.send_to_user(env.recipient_id, payload_ws) await messagingManager.send_to_user(env.recipient_id, payload_ws)
@@ -300,13 +305,7 @@ async def dm_send(
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": [
@@ -320,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))
@@ -337,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}")
@@ -503,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()
@@ -520,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,
} }
} }
@@ -552,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()
@@ -609,7 +671,7 @@ async def chat_websocket(
# File serving endpoints # File serving endpoints
@router.get("/files/normal/{filename}") @router.get("/uploads/files/normal/{filename}")
async def get_file_normal(filename: str): async def get_file_normal(filename: str):
if not re.match(r"^[A-Za-z0-9._-]+$", filename): if not re.match(r"^[A-Za-z0-9._-]+$", filename):
raise HTTPException(status_code=400, detail="Invalid file name") raise HTTPException(status_code=400, detail="Invalid file name")
@@ -619,11 +681,22 @@ async def get_file_normal(filename: str):
return FileResponse(str(path)) return FileResponse(str(path))
@router.get("/files/encrypted/{filename}") @router.get("/uploads/files/encrypted/{filename}")
async def get_file_encrypted(filename: str): async def get_file_encrypted(filename: str, current_user: User = Depends(get_current_user)):
if not re.match(r"^[A-Za-z0-9._-]+$", filename): if not re.match(r"^[A-Za-z0-9._-]+$", filename):
raise HTTPException(status_code=400, detail="Invalid file name") raise HTTPException(status_code=400, detail="Invalid file name")
path = FILES_ENCRYPTED_DIR / filename path = FILES_ENCRYPTED_DIR / filename
if not path.exists(): if not path.exists():
raise HTTPException(status_code=404, detail="File not found") 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)) return FileResponse(str(path))
+92 -2
View File
@@ -5,7 +5,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/s
import { randomBytes } from "../utils/crypto/kdf"; import { randomBytes } from "../utils/crypto/kdf";
import { getCurrentKeys } from "../auth/crypto"; import { getCurrentKeys } from "../auth/crypto";
import { request } from "../core/websocket"; import { request } from "../core/websocket";
import type { SendDMRequest, DmEnvelope, User } from "../core/types"; import type { SendDMRequest, DmEnvelope, User, DMEditWebSocketMessage, 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 DMEditWebSocketMessage);
}
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
await request({
type: "dmDelete",
credentials: { scheme: "Bearer", credentials: authToken },
data: { id, recipientId }
});
}
+66 -4
View File
@@ -155,6 +155,7 @@ export interface SendDMRequest {
salt: string; salt: string;
iv2: string; iv2: string;
wrappedMk: string; wrappedMk: string;
replyToId?: number;
} }
// Responses // Responses
@@ -174,22 +175,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
// --------------- // ---------------
@@ -231,6 +264,18 @@ export interface WebSocketCredentials {
credentials: string; credentials: string;
} }
export interface DMEditWebSocketMessage extends WebSocketMessage {
type: "dmEdit",
data: {
id: number;
iv: string;
ciphertext: string;
iv2: string;
wrappedMk: string;
salt: string;
}
}
export interface Attachment { export interface Attachment {
path: string; path: string;
encrypted: boolean; encrypted: boolean;
@@ -239,6 +284,23 @@ export interface Attachment {
size?: number; size?: number;
} }
// -----------
// Encrypted message JSON (plaintext structure before encryption)
// -----------
export type ChatMessageKind = "text"; // Extendable for future kinds
export interface EncryptedTextMessageData {
content: string;
files?: Attachment[];
reply_to_id?: number | null;
}
export interface EncryptedMessageJson {
type: ChatMessageKind;
data: EncryptedTextMessageData;
}
// ----------- // -----------
// React types // React types
// ----------- // -----------
+1 -1
View File
@@ -232,7 +232,7 @@
.quote.reply-preview { .quote.reply-preview {
user-select: none; user-select: none;
margin-bottom: 10px; margin: 10px;
} }
.message-attachments { .message-attachments {
@@ -18,7 +18,20 @@ interface ChatInputWrapperProps {
onCloseEdit?: () => void; onCloseEdit?: () => void;
} }
export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVisible, onClearReply, onCloseReply, editingMessage, editVisible = false, onClearEdit, onCloseEdit }: ChatInputWrapperProps) { export function ChatInputWrapper(
{
onSendMessage,
onSaveEdit,
replyTo,
replyToVisible,
onClearReply,
onCloseReply,
editingMessage,
editVisible = false,
onClearEdit,
onCloseEdit
}: ChatInputWrapperProps
) {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [attachmentsVisible, setAttachmentsVisible] = useState(false); const [attachmentsVisible, setAttachmentsVisible] = useState(false);
@@ -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,9 @@ 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}
dmEnvelope={(message as any).dmEnvelope} />
))} ))}
{children} {children}
</div> </div>
-128
View File
@@ -1,128 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { useAppState } from "../../state";
import { useDM } from "../../hooks/useDM";
import { ChatMessages } from "./ChatMessages";
import defaultAvatar from "../../../resources/images/default-avatar.png";
export function DMPanel() {
const { chat } = useAppState();
const { sendDMMessage, isLoadingHistory } = useDM();
const [message, setMessage] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const activeDm = chat.activeDm;
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [chat.messages]);
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim() || !activeDm?.publicKey) return;
try {
await sendDMMessage(activeDm.userId, activeDm.publicKey, message);
setMessage("");
} catch (error) {
console.error("Failed to send DM:", error);
}
};
const handleProfileClick = () => {
// TODO: Implement profile dialog for DM user
console.log("Profile clicked for DM user:", activeDm?.username);
};
if (!activeDm) {
return (
<div className="chat-main" id="chat-inner">
<div className="chat-header">
<img src={defaultAvatar} alt="Avatar" className="chat-header-avatar" />
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">Выберите пользователя</h4>
<p>
<span className="online-status"></span>
Выберите пользователя для начала разговора
</p>
</div>
</div>
</div>
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Выберите пользователя из списка для начала личных сообщений
</div>
</div>
</div>
);
}
return (
<div className="chat-main" id="chat-inner">
<div className="chat-header">
<img
src={defaultAvatar}
alt="Avatar"
className="chat-header-avatar"
onClick={handleProfileClick}
style={{ cursor: "pointer" }}
/>
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{activeDm.username}</h4>
<p>
<span className="online-status"></span>
Личные сообщения
</p>
</div>
<a href="#" id="hide-chat">Свернуть чат</a>
</div>
</div>
<div className="chat-messages" id="chat-messages">
{isLoadingHistory ? (
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Загрузка сообщений...
</div>
) : (
<>
<ChatMessages />
<div ref={messagesEndRef} />
</>
)}
</div>
<div className="chat-input-wrapper">
<div className="chat-input">
<form className="input-group" id="message-form" onSubmit={handleSendMessage}>
<input
type="text"
className="message-input"
id="message-input"
placeholder="Напишите сообщение..."
autoComplete="off"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button type="submit" className="send-btn">
<span className="material-symbols filled">send</span>
</button>
</form>
</div>
</div>
</div>
);
}
@@ -38,10 +38,7 @@ export function DMUsersList() {
if (!user.publicKey) { if (!user.publicKey) {
// Get public key if not already loaded // Get public key if not already loaded
const authToken = useAppState.getState().user.authToken; const authToken = useAppState.getState().user.authToken;
if (!authToken) { if (!authToken) return;
console.error("No auth token available");
return;
}
const publicKey = await fetchUserPublicKey(user.id, authToken); const publicKey = await fetchUserPublicKey(user.id, authToken);
if (publicKey) { if (publicKey) {
+84 -4
View File
@@ -5,6 +5,12 @@ 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 } from "react";
import { getCurrentKeys } from "../../../auth/crypto";
import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric";
import { getAuthHeaders } from "../../../auth/api";
import { useAppState } from "../../state";
import { ub64 } from "../../../utils/utils";
interface MessageProps { interface MessageProps {
message: MessageType; message: MessageType;
@@ -13,10 +19,18 @@ 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;
dmEnvelope?: {
salt: string;
iv2: string;
wrappedMk: string;
};
} }
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false }: MessageProps) { export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey, dmEnvelope }: MessageProps) {
const [formattedMessage, setFormattedMessage] = useState({ __html: DOMPurify.sanitize(message.content).trim() }); const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
const [decryptedFiles, setDecryptedFiles] = useState<Map<string, string>>(new Map());
const { user } = useAppState();
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -28,6 +42,54 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
})(); })();
}, [message]); }, [message]);
const decryptFile = async (file: any): Promise<string | null> => {
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null;
// Check if already decrypted
if (decryptedFiles.has(file.path)) {
return decryptedFiles.get(file.path) || null;
}
try {
// Fetch encrypted file
const response = await fetch(file.path, {
headers: getAuthHeaders(user.authToken!)
});
if (!response.ok) throw new Error("Failed to fetch file");
const encryptedData = await response.arrayBuffer();
// Get current user's keys
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Derive shared secret with the recipient's public key
const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey));
// Derive wrapping key using the salt from the DM envelope
const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Unwrap the message key
const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk));
// Decrypt the file using the message key
const iv = new Uint8Array(encryptedData, 0, 12);
const ciphertext = new Uint8Array(encryptedData, 12);
const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext);
// Create blob URL for download
const blob = new Blob([decrypted.buffer as ArrayBuffer]);
const url = URL.createObjectURL(blob);
setDecryptedFiles(prev => new Map(prev).set(file.path, url));
return url;
} catch (error) {
console.error("Failed to decrypt file:", error);
return null;
}
};
function handleContextMenu(e: React.MouseEvent) { function handleContextMenu(e: React.MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -81,13 +143,31 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
<mdui-list className="message-attachments"> <mdui-list className="message-attachments">
{message.files.map((file, idx) => { {message.files.map((file, idx) => {
const isImage = !file.encrypted && (file.content_type?.startsWith("image/") || /\.(png|jpg|jpeg|gif|webp)$/i.test(file.filename || "")); const isImage = !file.encrypted && (file.content_type?.startsWith("image/") || /\.(png|jpg|jpeg|gif|webp)$/i.test(file.filename || ""));
const downloadUrl = decryptedFiles.get(file.path) || file.path;
return ( return (
<div className="attachment" key={idx}> <div className="attachment" key={idx}>
{isImage ? ( {isImage ? (
<img src={file.path} alt={file.filename || "image"} style={{ maxWidth: "200px", borderRadius: "8px" }} /> <img src={file.path} alt={file.filename || "image"} style={{ maxWidth: "200px", borderRadius: "8px" }} />
) : ( ) : (
<a href={file.path} download target="_blank" rel="noreferrer"> <a
<mdui-list-item icon="download--filled">{file.filename || file.path.split("/").pop()}</mdui-list-item> href={downloadUrl}
download={file.filename || "file"}
target="_blank"
rel="noreferrer"
onClick={async (e) => {
if (file.encrypted && !decryptedFiles.has(file.path)) {
e.preventDefault();
const decryptedUrl = await decryptFile(file);
if (decryptedUrl) {
const link = document.createElement('a');
link.href = decryptedUrl;
link.download = file.filename || "file";
link.click();
}
}
}}
>
<mdui-list-item icon="download--filled">{(file.filename || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}</mdui-list-item>
</a> </a>
)} )}
</div> </div>
@@ -153,6 +153,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
<ChatMessages <ChatMessages
messages={panelState.messages} messages={panelState.messages}
isDm={panel.isDm()} isDm={panel.isDm()}
dmRecipientPublicKey={(panel as any).dmData?.publicKey}
onReplySelect={(message) => { onReplySelect={(message) => {
if (editMessage || editVisible) { if (editMessage || editVisible) {
setPendingAction({ type: "reply", message: message }); setPendingAction({ type: "reply", message: message });
@@ -169,6 +170,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
setEditMessage(message); setEditMessage(message);
} }
}} }}
onDelete={(id) => panel.handleDeleteMessage(id)}
> >
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</ChatMessages> </ChatMessages>
+4 -46
View File
@@ -7,7 +7,7 @@ import {
decryptDm, decryptDm,
sendDMViaWebSocket sendDMViaWebSocket
} from "../../api/dmApi"; } from "../../api/dmApi";
import type { User, Message } from "../../core/types"; import type { User, Message, DmEncryptedJSON } from "../../core/types";
import { websocket } from "../../core/websocket"; import { websocket } from "../../core/websocket";
interface DMUser extends User { interface DMUser extends User {
@@ -41,7 +41,8 @@ export function useDM() {
let lastPlaintext: string | null = null; let lastPlaintext: string | null = null;
try { try {
lastPlaintext = await decryptDm(lastMessage, publicKey); lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
console.log(lastPlaintext);
} catch (error) { } catch (error) {
console.error("Failed to decrypt last message:", error); console.error("Failed to decrypt last message:", error);
} }
@@ -93,50 +94,7 @@ export function useDM() {
// Load last messages and unread counts for visible users // Load last messages and unread counts for visible users
// Call loadUserLastMessage directly without dependency // Call loadUserLastMessage directly without dependency
for (const dmUser of dmUsersWithState) { for (const dmUser of dmUsersWithState) {
if (!user.authToken) continue; await loadUserLastMessage(dmUser);
try {
// Get public key
const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) continue;
// Get message history
const messages = await fetchDMHistory(dmUser.id, user.authToken, 50);
if (messages.length === 0) continue;
// Find last message
const lastMessage = messages[messages.length - 1];
let lastPlaintext: string | null = null;
try {
lastPlaintext = await decryptDm(lastMessage, publicKey);
} catch (error) {
console.error("Failed to decrypt last message:", error);
}
// Calculate unread count
const lastReadId = getLastReadId(dmUser.id);
let unreadCount = 0;
for (const msg of messages) {
if (msg.senderId === dmUser.id && msg.id > lastReadId) {
unreadCount++;
}
}
// Update user state
setDmUsersState(prev => prev.map(u =>
u.id === dmUser.id
? {
...u,
lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined,
unreadCount,
publicKey
}
: u
));
} catch (error) {
console.error("Failed to load last message for user:", dmUser.id, error);
}
} }
} catch (error) { } catch (error) {
console.error("Failed to load DM users:", error); console.error("Failed to load DM users:", error);
+126 -36
View File
@@ -1,11 +1,13 @@
import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from "./MessagePanel"; import { MessagePanel } from "./MessagePanel";
import { import {
fetchDMHistory, fetchDMHistory,
decryptDm, decryptDm,
sendDMViaWebSocket, sendDMViaWebSocket,
sendDmWithFiles sendDmWithFiles,
editDmEnvelope,
deleteDmEnvelope
} from "../../api/dmApi"; } from "../../api/dmApi";
import type { Message, WebSocketMessage } from "../../core/types"; import type { DmEncryptedJSON, DmEnvelope, EncryptedMessageJson, Message, WebSocketMessage } from "../../core/types";
import type { UserState } from "../state"; import type { UserState } from "../state";
export interface DMPanelData { export interface DMPanelData {
@@ -21,11 +23,9 @@ export class DMPanel extends MessagePanel {
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 {
@@ -42,6 +42,45 @@ 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 & { dmEnvelope?: { salt: string; iv2: string; wrappedMk: string } } = {
id: env.id,
content: content,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false,
files: env.files?.map(file => { return {"filename": file.name, "encrypted": true, "path": file.path} }) || [],
dmEnvelope: {
salt: env.salt,
iv2: env.iv2,
wrappedMk: env.wrappedMk
}
};
if (reply_to_id) {
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
if (referenced) dmMsg.reply_to = referenced;
}
return dmMsg;
}
async loadMessages(): Promise<void> { async loadMessages(): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return; if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return;
@@ -53,18 +92,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;
@@ -89,19 +118,27 @@ export class DMPanel extends MessagePanel {
} }
} }
async sendMessage(content: string, _replyToId?: number, files: File[] = []): 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 {
const payload: DmEncryptedJSON = {
type: "text",
data: {
content: content.trim(),
reply_to_id: replyToId ?? undefined
}
}
const json = JSON.stringify(payload);
if (files.length === 0) { if (files.length === 0) {
await sendDMViaWebSocket( await sendDMViaWebSocket(
this.dmData.userId, this.dmData.userId,
this.dmData.publicKey, this.dmData.publicKey,
content, json,
this.currentUser.authToken this.currentUser.authToken
); );
} else { } else {
const json = JSON.stringify({ type: "text", data: { content: content.trim() } });
await sendDmWithFiles( await sendDmWithFiles(
this.dmData.userId, this.dmData.userId,
this.dmData.publicKey, this.dmData.publicKey,
@@ -130,25 +167,16 @@ export class DMPanel extends MessagePanel {
// Handle incoming WebSocket DM messages // Handle incoming WebSocket DM messages
handleWebSocketMessage = async (response: WebSocketMessage): Promise<void> => { handleWebSocketMessage = async (response: WebSocketMessage): Promise<void> => {
if (response.type === "dmNew" && this.dmData) { if (response.type === "dmNew" && this.dmData) {
const { senderId, recipientId, ...envelope } = response.data; const envelope = response.data as DmEnvelope;
// If this is for the active DM conversation // If 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) {
@@ -156,6 +184,43 @@ export class DMPanel extends MessagePanel {
} }
} }
} }
if (response.type === "dmEdited" && this.dmData) {
const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
try {
// Decrypt new content in-place
const plaintext = await decryptDm(
{
id,
senderId: 0,
recipientId: 0,
iv,
ciphertext,
salt,
iv2,
wrappedMk,
timestamp: new Date().toISOString()
},
this.dmData.publicKey
);
let content = plaintext;
let files: Message["files"] | undefined = undefined;
try {
const obj = JSON.parse(plaintext) as EncryptedMessageJson;
if (obj.type === "text" && obj.data) {
content = obj.data.content;
files = obj.data.files;
}
} catch {}
const updates: Partial<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
@@ -191,4 +256,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 {}
} }
+6 -24
View File
@@ -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,8 +37,6 @@ 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
@@ -115,23 +110,10 @@ export abstract class MessagePanel {
} }
// Event handlers // Event handlers
handleSendMessage = (content: string, replyToId?: number, files: File[] = []): void => { handleSendMessage(content: string, replyToId?: number, files: File[] = []): void {
this.sendMessage(content, replyToId, files); this.sendMessage(content, replyToId, files);
}; };
abstract handleEditMessage(messageId: number, content: string): Promise<void>;
handleEditMessage = (messageId: number, content: string): void => { abstract handleDeleteMessage(messageId: number): Promise<void>;
this.callbacks.onEditMessage(messageId, content); abstract handleProfileClick(): void;
};
handleDeleteMessage = (messageId: number): void => {
this.callbacks.onDeleteMessage(messageId);
};
handleReplyToMessage = (messageId: number, content: string): void => {
this.callbacks.onReplyToMessage(messageId, content);
};
handleProfileClick = (): void => {
this.callbacks.onProfileClick();
};
} }
+35 -5
View File
@@ -1,4 +1,4 @@
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";
@@ -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"
@@ -137,4 +135,36 @@ export class PublicChatPanel extends MessagePanel {
setAuthToken(authToken: string): void { setAuthToken(authToken: string): void {
this.currentUser.authToken = authToken; this.currentUser.authToken = authToken;
} }
async handleEditMessage(messageId: number, content: string): Promise<void> {
if (!this.currentUser.authToken) return;
try {
await request({
type: "editMessage",
data: {
message_id: messageId,
content: content
},
credentials: {
scheme: "Bearer",
credentials: this.currentUser.authToken
}
});
} catch (error) {
console.error("Failed to edit message:", error);
}
}
async handleDeleteMessage(id: number): Promise<void> {
await request({
type: "deleteMessage",
data: { message_id: id },
credentials: {
scheme: "Bearer",
credentials: this.currentUser.authToken!
}
});
}
handleProfileClick(): void {}
} }
+2 -44
View File
@@ -277,37 +277,7 @@ export const useAppState = create<AppState>((set, get) => ({
// Create or get public chat panel // Create or get public chat panel
let publicChatPanel = chat.publicChatPanel; let publicChatPanel = chat.publicChatPanel;
if (!publicChatPanel) { if (!publicChatPanel) {
const callbacks = { publicChatPanel = new PublicChatPanel(chatName, user);
onSendMessage: (_content: string) => {},
onEditMessage: async (messageId: number, content: string) => {
if (!user.authToken) return;
try {
await request({
type: "editMessage",
data: {
message_id: messageId,
content: content
},
credentials: {
scheme: "Bearer",
credentials: user.authToken
}
});
} catch (error) {
console.error("Failed to edit message:", error);
}
},
onDeleteMessage: (_messageId: number) => {},
onReplyToMessage: (_messageId: number, _content: string) => {},
onProfileClick: () => {}
};
publicChatPanel = new PublicChatPanel(
chatName,
user,
callbacks,
() => {} // State change handled by MessagePanelRenderer
);
} else { } else {
publicChatPanel.setChatName(chatName); publicChatPanel.setChatName(chatName);
publicChatPanel.setAuthToken(user.authToken); publicChatPanel.setAuthToken(user.authToken);
@@ -346,19 +316,7 @@ export const useAppState = create<AppState>((set, get) => ({
// Create or get DM panel // Create or get DM panel
let dmPanel = chat.dmPanel; let dmPanel = chat.dmPanel;
if (!dmPanel) { if (!dmPanel) {
const callbacks = { dmPanel = new DMPanel(user);
onSendMessage: (_content: string) => {},
onEditMessage: (_messageId: number, _content: string) => {},
onDeleteMessage: (_messageId: number) => {},
onReplyToMessage: (_messageId: number, _content: string) => {},
onProfileClick: () => {}
};
dmPanel = new DMPanel(
user,
callbacks,
() => {} // State change handled by MessagePanelRenderer
);
} else { } else {
dmPanel.setAuthToken(user.authToken); dmPanel.setAuthToken(user.authToken);
} }