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
+128 -38
View File
@@ -1,11 +1,13 @@
import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from "./MessagePanel";
import { MessagePanel } from "./MessagePanel";
import {
fetchDMHistory,
decryptDm,
sendDMViaWebSocket,
sendDmWithFiles
sendDmWithFiles,
editDmEnvelope,
deleteDmEnvelope
} from "../../api/dmApi";
import type { Message, WebSocketMessage } from "../../core/types";
import type { DmEncryptedJSON, DmEnvelope, EncryptedMessageJson, Message, WebSocketMessage } from "../../core/types";
import type { UserState } from "../state";
export interface DMPanelData {
@@ -21,11 +23,9 @@ export class DMPanel extends MessagePanel {
private messagesLoaded: boolean = false;
constructor(
user: UserState,
callbacks: MessagePanelCallbacks,
onStateChange: (state: MessagePanelState) => void
user: UserState
) {
super("dm", user, callbacks, onStateChange);
super("dm", user);
}
isDm(): boolean {
@@ -42,6 +42,45 @@ export class DMPanel extends MessagePanel {
// DM doesn't need special cleanup
}
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
const plaintext = await decryptDm(env, this.dmData!.publicKey);
const isAuthor = env.senderId !== this.dmData!.userId;
const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username;
// Try parse JSON payload { type: "text", data: { content, files?, reply_to_id? } }
let content = plaintext;
let reply_to_id: number | undefined = undefined;
try {
const obj = JSON.parse(plaintext) as DmEncryptedJSON;
if (obj && obj.type === "text" && obj.data) {
content = obj.data.content;
reply_to_id = Number(obj.data.reply_to_id) || undefined;
}
} catch {}
const dmMsg: Message & { dmEnvelope?: { salt: string; iv2: string; wrappedMk: string } } = {
id: env.id,
content: content,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false,
files: env.files?.map(file => { return {"filename": file.name, "encrypted": true, "path": file.path} }) || [],
dmEnvelope: {
salt: env.salt,
iv2: env.iv2,
wrappedMk: env.wrappedMk
}
};
if (reply_to_id) {
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
if (referenced) dmMsg.reply_to = referenced;
}
return dmMsg;
}
async loadMessages(): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return;
@@ -53,18 +92,8 @@ export class DMPanel extends MessagePanel {
for (const env of messages) {
try {
const text = await decryptDm(env, this.dmData!.publicKey);
const isAuthor = env.senderId !== this.dmData!.userId;
const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username;
decryptedMessages.push({
id: env.id,
content: text,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false
});
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
decryptedMessages.push(dmMsg);
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
maxIncomingId = env.id;
@@ -89,19 +118,27 @@ export class DMPanel extends MessagePanel {
}
}
async sendMessage(content: string, _replyToId?: number, files: File[] = []): Promise<void> {
async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
try {
const payload: DmEncryptedJSON = {
type: "text",
data: {
content: content.trim(),
reply_to_id: replyToId ?? undefined
}
}
const json = JSON.stringify(payload);
if (files.length === 0) {
await sendDMViaWebSocket(
this.dmData.userId,
this.dmData.publicKey,
content,
this.dmData.userId,
this.dmData.publicKey,
json,
this.currentUser.authToken
);
} else {
const json = JSON.stringify({ type: "text", data: { content: content.trim() } });
await sendDmWithFiles(
this.dmData.userId,
this.dmData.publicKey,
@@ -130,25 +167,16 @@ export class DMPanel extends MessagePanel {
// Handle incoming WebSocket DM messages
handleWebSocketMessage = async (response: WebSocketMessage): Promise<void> => {
if (response.type === "dmNew" && this.dmData) {
const { senderId, recipientId, ...envelope } = response.data;
const envelope = response.data as DmEnvelope;
// If this is for the active DM conversation
if (senderId === this.dmData.userId || recipientId === this.dmData.userId) {
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
try {
const plaintext = await decryptDm(envelope, this.dmData.publicKey);
const isAuthor = senderId !== this.dmData.userId;
this.addMessage({
id: envelope.id,
content: plaintext,
username: isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData.username,
timestamp: envelope.timestamp,
is_read: false,
is_edited: false
});
const dmMsg = await this.parseTextPayload(envelope, this.getMessages());
this.addMessage(dmMsg);
// Update last read if it's from the other user
if (senderId === this.dmData.userId) {
if (envelope.senderId === this.dmData.userId) {
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
}
} catch (error) {
@@ -156,6 +184,43 @@ export class DMPanel extends MessagePanel {
}
}
}
if (response.type === "dmEdited" && this.dmData) {
const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
try {
// Decrypt new content in-place
const plaintext = await decryptDm(
{
id,
senderId: 0,
recipientId: 0,
iv,
ciphertext,
salt,
iv2,
wrappedMk,
timestamp: new Date().toISOString()
},
this.dmData.publicKey
);
let content = plaintext;
let files: Message["files"] | undefined = undefined;
try {
const obj = JSON.parse(plaintext) as EncryptedMessageJson;
if (obj.type === "text" && obj.data) {
content = obj.data.content;
files = obj.data.files;
}
} catch {}
const updates: Partial<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
@@ -191,4 +256,29 @@ export class DMPanel extends MessagePanel {
localStorage.setItem(`dmLastRead:${userId}`, String(id));
} 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 {
protected state: MessagePanelState;
protected callbacks: MessagePanelCallbacks;
public onStateChange: ((state: MessagePanelState) => void) | null;
protected currentUser: UserState;
public onStateChange: ((state: MessagePanelState) => void) | null = () => {};
protected readonly currentUser: UserState;
constructor(
id: string,
currentUser: UserState,
callbacks: MessagePanelCallbacks,
onStateChange: (state: MessagePanelState) => void
) {
this.state = {
id,
@@ -40,8 +37,6 @@ export abstract class MessagePanel {
isTyping: false
};
this.currentUser = currentUser;
this.callbacks = callbacks;
this.onStateChange = onStateChange;
}
// Abstract methods that must be implemented by subclasses
@@ -115,23 +110,10 @@ export abstract class MessagePanel {
}
// Event handlers
handleSendMessage = (content: string, replyToId?: number, files: File[] = []): void => {
handleSendMessage(content: string, replyToId?: number, files: File[] = []): void {
this.sendMessage(content, replyToId, files);
};
handleEditMessage = (messageId: number, content: string): void => {
this.callbacks.onEditMessage(messageId, content);
};
handleDeleteMessage = (messageId: number): void => {
this.callbacks.onDeleteMessage(messageId);
};
handleReplyToMessage = (messageId: number, content: string): void => {
this.callbacks.onReplyToMessage(messageId, content);
};
handleProfileClick = (): void => {
this.callbacks.onProfileClick();
};
abstract handleEditMessage(messageId: number, content: string): Promise<void>;
abstract handleDeleteMessage(messageId: number): Promise<void>;
abstract handleProfileClick(): void;
}
+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 { getAuthHeaders } from "../../auth/api";
import { request } from "../../core/websocket";
@@ -10,11 +10,9 @@ export class PublicChatPanel extends MessagePanel {
constructor(
chatName: string,
currentUser: UserState,
callbacks: MessagePanelCallbacks,
onStateChange: (state: MessagePanelState) => void
currentUser: UserState
) {
super(`public-${chatName}`, currentUser, callbacks, onStateChange);
super(`public-${chatName}`, currentUser);
this.updateState({
title: chatName,
online: true // Public chats are always "online"
@@ -137,4 +135,36 @@ export class PublicChatPanel extends MessagePanel {
setAuthToken(authToken: string): void {
this.currentUser.authToken = authToken;
}
async handleEditMessage(messageId: number, content: string): Promise<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 {}
}