From 3b1aab498594621f667a1b63ded77c0c2a6cfb68 Mon Sep 17 00:00:00 2001 From: Myx Date: Mon, 9 Mar 2026 22:12:08 +0100 Subject: [PATCH] Messages now actually gets deleted --- electron/cqrs/mappers.ts | 10 +- electron/ipc/system.ts | 13 + electron/preload.ts | 2 + src/app/core/models/index.ts | 3 + src/app/core/services/attachment.service.ts | 105 ++++ .../core/services/browser-database.service.ts | 20 +- .../chat-message-item.component.html | 480 +++++++++--------- .../chat-message-item.component.ts | 6 +- .../messages/messages-incoming.handlers.ts | 57 ++- src/app/store/messages/messages.effects.ts | 47 +- src/app/store/messages/messages.helpers.ts | 58 ++- src/app/store/messages/messages.reducer.ts | 12 +- 12 files changed, 536 insertions(+), 277 deletions(-) diff --git a/electron/cqrs/mappers.ts b/electron/cqrs/mappers.ts index a0f7463..3df8568 100644 --- a/electron/cqrs/mappers.ts +++ b/electron/cqrs/mappers.ts @@ -10,18 +10,22 @@ import { ReactionEntity } from '../entities/ReactionEntity'; import { BanEntity } from '../entities/BanEntity'; import { AttachmentEntity } from '../entities/AttachmentEntity'; +const DELETED_MESSAGE_CONTENT = '[Message deleted]'; + export function rowToMessage(row: MessageEntity) { + const isDeleted = !!row.isDeleted; + return { id: row.id, roomId: row.roomId, channelId: row.channelId ?? undefined, senderId: row.senderId, senderName: row.senderName, - content: row.content, + content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content, timestamp: row.timestamp, editedAt: row.editedAt ?? undefined, - reactions: JSON.parse(row.reactions || '[]') as unknown[], - isDeleted: !!row.isDeleted, + reactions: isDeleted ? [] : JSON.parse(row.reactions || '[]') as unknown[], + isDeleted, replyToId: row.replyToId ?? undefined }; } diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index a77fa1e..befd1b7 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -95,6 +95,19 @@ export function setupSystemHandlers(): void { return true; }); + ipcMain.handle('delete-file', async (_event, filePath: string) => { + try { + await fsp.unlink(filePath); + return true; + } catch (error) { + if ((error as { code?: string }).code === 'ENOENT') { + return true; + } + + throw error; + } + }); + ipcMain.handle('save-file-as', async (_event, defaultFileName: string, base64Data: string) => { const result = await dialog.showSaveDialog({ defaultPath: defaultFileName diff --git a/electron/preload.ts b/electron/preload.ts index 83a4a11..4dfbff3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -62,6 +62,7 @@ export interface ElectronAPI { writeFile: (filePath: string, data: string) => Promise; saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>; fileExists: (filePath: string) => Promise; + deleteFile: (filePath: string) => Promise; ensureDir: (dirPath: string) => Promise; command: (command: Command) => Promise; @@ -117,6 +118,7 @@ const electronAPI: ElectronAPI = { writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data), saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data), fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath), + deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath), ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath), command: (command) => ipcRenderer.invoke('cqrs:command', command), diff --git a/src/app/core/models/index.ts b/src/app/core/models/index.ts index 895ff2f..e45d09f 100644 --- a/src/app/core/models/index.ts +++ b/src/app/core/models/index.ts @@ -4,6 +4,8 @@ export type UserRole = 'host' | 'admin' | 'moderator' | 'member'; export type ChannelType = 'text' | 'voice'; +export const DELETED_MESSAGE_CONTENT = '[Message deleted]'; + export interface User { id: string; oderId: string; @@ -214,6 +216,7 @@ export interface ChatEvent { bannedBy?: string; content?: string; editedAt?: number; + deletedAt?: number; deletedBy?: string; oderId?: string; displayName?: string; diff --git a/src/app/core/services/attachment.service.ts b/src/app/core/services/attachment.service.ts index 8ac99a0..7732c41 100644 --- a/src/app/core/services/attachment.service.ts +++ b/src/app/core/services/attachment.service.ts @@ -145,6 +145,39 @@ export class AttachmentService { return this.attachmentsByMessage.get(messageId) ?? []; } + /** Remove every attachment associated with a message. */ + async deleteForMessage(messageId: string): Promise { + const attachments = this.attachmentsByMessage.get(messageId) ?? []; + const hadCachedAttachments = attachments.length > 0 || this.attachmentsByMessage.has(messageId); + const retainedSavedPaths = await this.getRetainedSavedPathsForOtherMessages(messageId); + const savedPathsToDelete = new Set(); + + for (const attachment of attachments) { + if (attachment.objectUrl) { + try { URL.revokeObjectURL(attachment.objectUrl); } catch { /* ignore */ } + } + + if (attachment.savedPath && !retainedSavedPaths.has(attachment.savedPath)) { + savedPathsToDelete.add(attachment.savedPath); + } + } + + this.attachmentsByMessage.delete(messageId); + this.clearMessageScopedState(messageId); + + if (hadCachedAttachments) { + this.touch(); + } + + if (this.database.isReady()) { + await this.database.deleteAttachmentsForMessage(messageId); + } + + for (const diskPath of savedPathsToDelete) { + await this.deleteSavedFile(diskPath); + } + } + /** * Build a map of minimal attachment metadata for a set of message IDs. * Used during inventory-based message synchronisation so that peers @@ -677,6 +710,78 @@ export class AttachmentService { return `${messageId}:${fileId}`; } + private clearMessageScopedState(messageId: string): void { + const scopedPrefix = `${messageId}:`; + + for (const key of Array.from(this.originalFiles.keys())) { + if (key.startsWith(scopedPrefix)) { + this.originalFiles.delete(key); + } + } + + for (const key of Array.from(this.pendingRequests.keys())) { + if (key.startsWith(scopedPrefix)) { + this.pendingRequests.delete(key); + } + } + + for (const key of Array.from(this.chunkBuffers.keys())) { + if (key.startsWith(scopedPrefix)) { + this.chunkBuffers.delete(key); + } + } + + for (const key of Array.from(this.chunkCounts.keys())) { + if (key.startsWith(scopedPrefix)) { + this.chunkCounts.delete(key); + } + } + + for (const key of Array.from(this.cancelledTransfers)) { + if (key.startsWith(scopedPrefix)) { + this.cancelledTransfers.delete(key); + } + } + } + + private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise> { + const retainedSavedPaths = new Set(); + + for (const [existingMessageId, attachments] of this.attachmentsByMessage) { + if (existingMessageId === messageId) + continue; + + for (const attachment of attachments) { + if (attachment.savedPath) { + retainedSavedPaths.add(attachment.savedPath); + } + } + } + + if (!this.database.isReady()) { + return retainedSavedPaths; + } + + const persistedAttachments = await this.database.getAllAttachments(); + + for (const attachment of persistedAttachments) { + if (attachment.messageId !== messageId && attachment.savedPath) { + retainedSavedPaths.add(attachment.savedPath); + } + } + + return retainedSavedPaths; + } + + private async deleteSavedFile(filePath: string): Promise { + const electronApi = (window as any)?.electronAPI; + + if (!electronApi?.deleteFile) + return; + + await electronApi.deleteFile(filePath); + } + /** Clear any user-facing request error stored on an attachment. */ private clearAttachmentRequestError(attachment: Attachment): boolean { if (!attachment.requestError) diff --git a/src/app/core/services/browser-database.service.ts b/src/app/core/services/browser-database.service.ts index 08be197..5289ee8 100644 --- a/src/app/core/services/browser-database.service.ts +++ b/src/app/core/services/browser-database.service.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ import { Injectable } from '@angular/core'; import { + DELETED_MESSAGE_CONTENT, Message, User, Room, @@ -58,7 +59,6 @@ export class BrowserDatabaseService { /** * Retrieve messages for a room, sorted oldest-first. - * * @param roomId - Target room. * @param limit - Maximum number of messages to return. * @param offset - Number of messages to skip (for pagination). @@ -70,7 +70,8 @@ export class BrowserDatabaseService { return allRoomMessages .sort((first, second) => first.timestamp - second.timestamp) - .slice(offset, offset + limit); + .slice(offset, offset + limit) + .map((message) => this.normaliseMessage(message)); } /** Delete a message by its ID. */ @@ -90,7 +91,9 @@ export class BrowserDatabaseService { /** Retrieve a single message by ID, or `null` if not found. */ async getMessageById(messageId: string): Promise { - return (await this.get(STORE_MESSAGES, messageId)) ?? null; + const message = await this.get(STORE_MESSAGES, messageId); + + return message ? this.normaliseMessage(message) : null; } /** Remove every message belonging to a room. */ @@ -437,4 +440,15 @@ export class BrowserDatabaseService { transaction.onerror = () => reject(transaction.error); }); } + + private normaliseMessage(message: Message): Message { + if (!message.isDeleted) + return message; + + return { + ...message, + content: DELETED_MESSAGE_CONTENT, + reactions: [] + }; + } } diff --git a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.html b/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.html index 604543b..3ccc904 100644 --- a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.html +++ b/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.html @@ -26,7 +26,7 @@ /> @if (reply) { {{ reply.senderName }} - {{ reply.content }} + {{ reply.isDeleted ? deletedMessageContent : reply.content }} } @else { Original message not found } @@ -36,7 +36,7 @@
{{ msg.senderName }} {{ formatTimestamp(msg.timestamp) }} - @if (msg.editedAt) { + @if (msg.editedAt && !msg.isDeleted) { (edited) }
@@ -70,269 +70,273 @@ } @else { -
- - {{ deletedMessageContent }}
+ } @else { +
+ - @if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) { - - } @else { -
{{ node.value }}
- } - - -
- - @if (isKlipyMediaUrl(node.url)) { - - KLIPY - - } -
-
-
-
- - @if (attachmentsList.length > 0) { -
- @for (att of attachmentsList; track att.id) { - @if (att.isImage) { - @if (att.available && att.objectUrl) { -
- -
-
- - -
-
- } @else if ((att.receivedBytes || 0) > 0) { -
-
-
- -
-
-
{{ att.filename }}
-
{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}
-
-
{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%
-
-
-
-
-
+ + @if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) { + } @else { -
-
-
- -
-
-
{{ att.filename }}
-
{{ formatBytes(att.size) }}
-
- {{ att.requestError || 'Waiting for image source…' }} -
-
-
- -
- } - } @else if (att.isVideo || att.isAudio) { - @if (att.available && att.objectUrl) { - @if (att.isVideo) { - - } @else { - + KLIPY + } - } @else if ((att.receivedBytes || 0) > 0) { -
-
-
-
{{ att.filename }}
-
{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}
-
- -
-
-
-
-
- {{ att.progressPercent | number: '1.0-0' }}% - @if (att.speedBps) { - {{ formatSpeed(att.speedBps) }} - } -
-
- } @else { -
-
-
-
{{ att.filename }}
-
{{ formatBytes(att.size) }}
-
+ + +
+ + @if (attachmentsList.length > 0) { +
+ @for (att of attachmentsList; track att.id) { + @if (att.isImage) { + @if (att.available && att.objectUrl) { +
+ +
+
+ + +
+
+ } @else if ((att.receivedBytes || 0) > 0) { +
+
+
+ +
+
+
{{ att.filename }}
+
{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}
+
+
{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%
+
+
+
+
+
+ } @else { +
+
+
+ +
+
+
{{ att.filename }}
+
{{ formatBytes(att.size) }}
+
+ {{ att.requestError || 'Waiting for image source…' }} +
-
- } - } @else { -
-
-
-
{{ att.filename }}
-
{{ formatBytes(att.size) }}
+ } + } @else if (att.isVideo || att.isAudio) { + @if (att.available && att.objectUrl) { + @if (att.isVideo) { + + } @else { + + } + } @else if ((att.receivedBytes || 0) > 0) { +
+
+
+
{{ att.filename }}
+
{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}
+
+ +
+
+
+
+
+ {{ att.progressPercent | number: '1.0-0' }}% + @if (att.speedBps) { + {{ formatSpeed(att.speedBps) }} + } +
-
- @if (!att.isUploader) { - @if (!att.available) { -
-
+ } @else { +
+
+
+
{{ att.filename }}
+
{{ formatBytes(att.size) }}
+
+ {{ att.mediaStatusText }}
-
- {{ att.progressPercent | number: '1.0-0' }}% - @if (att.speedBps) { - • {{ formatSpeed(att.speedBps) }} +
+ +
+
+ } + } @else { +
+
+
+
{{ att.filename }}
+
{{ formatBytes(att.size) }}
+
+
+ @if (!att.isUploader) { + @if (!att.available) { +
+
+
+
+ {{ att.progressPercent | number: '1.0-0' }}% + @if (att.speedBps) { + • {{ formatSpeed(att.speedBps) }} + } +
+ @if (!(att.receivedBytes || 0)) { + + } @else { + } -
- @if (!(att.receivedBytes || 0)) { - } @else { } } @else { - +
Shared from your device
} - } @else { -
Shared from your device
- } +
+ @if (!att.available && att.requestError) { +
+ {{ att.requestError }} +
+ }
- @if (!att.available && att.requestError) { -
- {{ att.requestError }} -
- } -
+ } } - } -
+
+ } } } - @if (msg.reactions.length > 0) { + @if (!msg.isDeleted && msg.reactions.length > 0) {
@for (reaction of getGroupedReactions(); track reaction.emoji) {