Messages now actually gets deleted

This commit is contained in:
2026-03-09 22:12:08 +01:00
parent a55694af8e
commit 3b1aab4985
12 changed files with 536 additions and 277 deletions

View File

@@ -10,18 +10,22 @@ import { ReactionEntity } from '../entities/ReactionEntity';
import { BanEntity } from '../entities/BanEntity'; import { BanEntity } from '../entities/BanEntity';
import { AttachmentEntity } from '../entities/AttachmentEntity'; import { AttachmentEntity } from '../entities/AttachmentEntity';
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
export function rowToMessage(row: MessageEntity) { export function rowToMessage(row: MessageEntity) {
const isDeleted = !!row.isDeleted;
return { return {
id: row.id, id: row.id,
roomId: row.roomId, roomId: row.roomId,
channelId: row.channelId ?? undefined, channelId: row.channelId ?? undefined,
senderId: row.senderId, senderId: row.senderId,
senderName: row.senderName, senderName: row.senderName,
content: row.content, content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
timestamp: row.timestamp, timestamp: row.timestamp,
editedAt: row.editedAt ?? undefined, editedAt: row.editedAt ?? undefined,
reactions: JSON.parse(row.reactions || '[]') as unknown[], reactions: isDeleted ? [] : JSON.parse(row.reactions || '[]') as unknown[],
isDeleted: !!row.isDeleted, isDeleted,
replyToId: row.replyToId ?? undefined replyToId: row.replyToId ?? undefined
}; };
} }

View File

@@ -95,6 +95,19 @@ export function setupSystemHandlers(): void {
return true; 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) => { ipcMain.handle('save-file-as', async (_event, defaultFileName: string, base64Data: string) => {
const result = await dialog.showSaveDialog({ const result = await dialog.showSaveDialog({
defaultPath: defaultFileName defaultPath: defaultFileName

View File

@@ -62,6 +62,7 @@ export interface ElectronAPI {
writeFile: (filePath: string, data: string) => Promise<boolean>; writeFile: (filePath: string, data: string) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>; saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
fileExists: (filePath: string) => Promise<boolean>; fileExists: (filePath: string) => Promise<boolean>;
deleteFile: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>; ensureDir: (dirPath: string) => Promise<boolean>;
command: <T = unknown>(command: Command) => Promise<T>; command: <T = unknown>(command: Command) => Promise<T>;
@@ -117,6 +118,7 @@ const electronAPI: ElectronAPI = {
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data), writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data), saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath), fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath), ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
command: (command) => ipcRenderer.invoke('cqrs:command', command), command: (command) => ipcRenderer.invoke('cqrs:command', command),

View File

@@ -4,6 +4,8 @@ export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
export type ChannelType = 'text' | 'voice'; export type ChannelType = 'text' | 'voice';
export const DELETED_MESSAGE_CONTENT = '[Message deleted]';
export interface User { export interface User {
id: string; id: string;
oderId: string; oderId: string;
@@ -214,6 +216,7 @@ export interface ChatEvent {
bannedBy?: string; bannedBy?: string;
content?: string; content?: string;
editedAt?: number; editedAt?: number;
deletedAt?: number;
deletedBy?: string; deletedBy?: string;
oderId?: string; oderId?: string;
displayName?: string; displayName?: string;

View File

@@ -145,6 +145,39 @@ export class AttachmentService {
return this.attachmentsByMessage.get(messageId) ?? []; return this.attachmentsByMessage.get(messageId) ?? [];
} }
/** Remove every attachment associated with a message. */
async deleteForMessage(messageId: string): Promise<void> {
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<string>();
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. * Build a map of minimal attachment metadata for a set of message IDs.
* Used during inventory-based message synchronisation so that peers * Used during inventory-based message synchronisation so that peers
@@ -677,6 +710,78 @@ export class AttachmentService {
return `${messageId}:${fileId}`; 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<Set<string>> {
const retainedSavedPaths = new Set<string>();
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<void> {
const electronApi = (window as any)?.electronAPI;
if (!electronApi?.deleteFile)
return;
await electronApi.deleteFile(filePath);
}
/** Clear any user-facing request error stored on an attachment. */ /** Clear any user-facing request error stored on an attachment. */
private clearAttachmentRequestError(attachment: Attachment): boolean { private clearAttachmentRequestError(attachment: Attachment): boolean {
if (!attachment.requestError) if (!attachment.requestError)

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { import {
DELETED_MESSAGE_CONTENT,
Message, Message,
User, User,
Room, Room,
@@ -58,7 +59,6 @@ export class BrowserDatabaseService {
/** /**
* Retrieve messages for a room, sorted oldest-first. * Retrieve messages for a room, sorted oldest-first.
*
* @param roomId - Target room. * @param roomId - Target room.
* @param limit - Maximum number of messages to return. * @param limit - Maximum number of messages to return.
* @param offset - Number of messages to skip (for pagination). * @param offset - Number of messages to skip (for pagination).
@@ -70,7 +70,8 @@ export class BrowserDatabaseService {
return allRoomMessages return allRoomMessages
.sort((first, second) => first.timestamp - second.timestamp) .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. */ /** Delete a message by its ID. */
@@ -90,7 +91,9 @@ export class BrowserDatabaseService {
/** Retrieve a single message by ID, or `null` if not found. */ /** Retrieve a single message by ID, or `null` if not found. */
async getMessageById(messageId: string): Promise<Message | null> { async getMessageById(messageId: string): Promise<Message | null> {
return (await this.get<Message>(STORE_MESSAGES, messageId)) ?? null; const message = await this.get<Message>(STORE_MESSAGES, messageId);
return message ? this.normaliseMessage(message) : null;
} }
/** Remove every message belonging to a room. */ /** Remove every message belonging to a room. */
@@ -437,4 +440,15 @@ export class BrowserDatabaseService {
transaction.onerror = () => reject(transaction.error); transaction.onerror = () => reject(transaction.error);
}); });
} }
private normaliseMessage(message: Message): Message {
if (!message.isDeleted)
return message;
return {
...message,
content: DELETED_MESSAGE_CONTENT,
reactions: []
};
}
} }

View File

@@ -26,7 +26,7 @@
/> />
@if (reply) { @if (reply) {
<span class="font-medium">{{ reply.senderName }}</span> <span class="font-medium">{{ reply.senderName }}</span>
<span class="max-w-[200px] truncate">{{ reply.content }}</span> <span class="max-w-[200px] truncate">{{ reply.isDeleted ? deletedMessageContent : reply.content }}</span>
} @else { } @else {
<span class="italic">Original message not found</span> <span class="italic">Original message not found</span>
} }
@@ -36,7 +36,7 @@
<div class="flex items-baseline gap-2"> <div class="flex items-baseline gap-2">
<span class="font-semibold text-foreground">{{ msg.senderName }}</span> <span class="font-semibold text-foreground">{{ msg.senderName }}</span>
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span> <span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
@if (msg.editedAt) { @if (msg.editedAt && !msg.isDeleted) {
<span class="text-xs text-muted-foreground">(edited)</span> <span class="text-xs text-muted-foreground">(edited)</span>
} }
</div> </div>
@@ -70,269 +70,273 @@
</button> </button>
</div> </div>
} @else { } @else {
<div class="chat-markdown mt-1 break-words"> @if (msg.isDeleted) {
<remark <div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
[markdown]="msg.content" } @else {
[processor]="$any(remarkProcessor)" <div class="chat-markdown mt-1 break-words">
> <remark
<ng-template [markdown]="msg.content"
[remarkTemplate]="'code'" [processor]="$any(remarkProcessor)"
let-node
> >
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) { <ng-template
<remark-mermaid [code]="getMermaidCode(node.value)" /> [remarkTemplate]="'code'"
} @else { let-node
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre> >
} @if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
</ng-template> <remark-mermaid [code]="getMermaidCode(node.value)" />
<ng-template
[remarkTemplate]="'image'"
let-node
>
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
<img
[src]="getMarkdownImageSource(node.url)"
[alt]="node.alt || 'Shared image'"
class="block max-h-80 max-w-full w-auto"
loading="lazy"
/>
@if (isKlipyMediaUrl(node.url)) {
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
</span>
}
</div>
</ng-template>
</remark>
</div>
@if (attachmentsList.length > 0) {
<div class="mt-2 space-y-2">
@for (att of attachmentsList; track att.id) {
@if (att.isImage) {
@if (att.available && att.objectUrl) {
<div
class="group/img relative inline-block"
(contextmenu)="openImageContextMenu($event, att)"
>
<img
[src]="att.objectUrl"
[alt]="att.filename"
class="max-h-80 w-auto cursor-pointer rounded-md"
(click)="openLightbox(att)"
/>
<div class="pointer-events-none absolute inset-0 rounded-md bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
<div class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover/img:opacity-100">
<button
(click)="openLightbox(att); $event.stopPropagation()"
class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="View full size"
>
<ng-icon
name="lucideExpand"
class="h-4 w-4"
/>
</button>
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
>
<ng-icon
name="lucideDownload"
class="h-4 w-4"
/>
</button>
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<div class="max-w-xs rounded-md border border-border bg-secondary/40 p-3">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
<ng-icon
name="lucideImage"
class="h-5 w-5 text-primary"
/>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</div>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
></div>
</div>
</div>
} @else { } @else {
<div class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4"> <pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
<div class="flex items-center gap-3"> }
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted"> </ng-template>
<ng-icon <ng-template
name="lucideImage" [remarkTemplate]="'image'"
class="h-5 w-5 text-muted-foreground" let-node
/> >
</div> <div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
<div class="min-w-0 flex-1"> <img
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div> [src]="getMarkdownImageSource(node.url)"
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div> [alt]="node.alt || 'Shared image'"
<div class="block max-h-80 max-w-full w-auto"
class="mt-0.5 text-xs" loading="lazy"
[class.italic]="!att.requestError" />
[class.opacity-70]="!att.requestError" @if (isKlipyMediaUrl(node.url)) {
[class.text-destructive]="!!att.requestError" <span
[class.text-muted-foreground]="!att.requestError" class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
{{ att.requestError || 'Waiting for image source…' }}
</div>
</div>
</div>
<button
(click)="retryImageRequest(att)"
class="mt-2 w-full rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
> >
Retry KLIPY
</button> </span>
</div>
}
} @else if (att.isVideo || att.isAudio) {
@if (att.available && att.objectUrl) {
@if (att.isVideo) {
<app-chat-video-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
} @else {
<app-chat-audio-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
} }
} @else if ((att.receivedBytes || 0) > 0) { </div>
<div class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"> </ng-template>
<div class="flex items-start justify-between gap-3"> </remark>
<div class="min-w-0 flex-1"> </div>
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div> @if (attachmentsList.length > 0) {
</div> <div class="mt-2 space-y-2">
<button @for (att of attachmentsList; track att.id) {
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground" @if (att.isImage) {
(click)="cancelAttachment(att)" @if (att.available && att.objectUrl) {
> <div
Cancel class="group/img relative inline-block"
</button> (contextmenu)="openImageContextMenu($event, att)"
</div> >
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted"> <img
<div [src]="att.objectUrl"
class="h-full rounded-full bg-primary transition-all duration-300" [alt]="att.filename"
[style.width.%]="att.progressPercent" class="max-h-80 w-auto cursor-pointer rounded-md"
></div> (click)="openLightbox(att)"
</div> />
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground"> <div class="pointer-events-none absolute inset-0 rounded-md bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
<span>{{ att.progressPercent | number: '1.0-0' }}%</span> <div class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover/img:opacity-100">
@if (att.speedBps) { <button
<span>{{ formatSpeed(att.speedBps) }}</span> (click)="openLightbox(att); $event.stopPropagation()"
} class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
</div> title="View full size"
</div>
} @else {
<div class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div
class="mt-1 text-xs leading-relaxed"
[class.opacity-80]="!att.requestError"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
> >
{{ att.mediaStatusText }} <ng-icon
name="lucideExpand"
class="h-4 w-4"
/>
</button>
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
>
<ng-icon
name="lucideDownload"
class="h-4 w-4"
/>
</button>
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<div class="max-w-xs rounded-md border border-border bg-secondary/40 p-3">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
<ng-icon
name="lucideImage"
class="h-5 w-5 text-primary"
/>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</div>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
></div>
</div>
</div>
} @else {
<div class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
<ng-icon
name="lucideImage"
class="h-5 w-5 text-muted-foreground"
/>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div
class="mt-0.5 text-xs"
[class.italic]="!att.requestError"
[class.opacity-70]="!att.requestError"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.requestError || 'Waiting for image source…' }}
</div>
</div> </div>
</div> </div>
<button <button
(click)="requestAttachment(att)" (click)="retryImageRequest(att)"
class="shrink-0 rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80" class="mt-2 w-full rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
> >
{{ att.mediaActionLabel }} Retry
</button> </button>
</div> </div>
</div> }
} } @else if (att.isVideo || att.isAudio) {
} @else { @if (att.available && att.objectUrl) {
<div class="rounded-md border border-border bg-secondary/40 p-2"> @if (att.isVideo) {
<div class="flex items-center justify-between"> <app-chat-video-player
<div class="min-w-0"> [src]="att.objectUrl"
<div class="truncate text-sm font-medium">{{ att.filename }}</div> [filename]="att.filename"
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div> [sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
} @else {
<app-chat-audio-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
}
} @else if ((att.receivedBytes || 0) > 0) {
<div class="max-w-xl rounded-md border border-border bg-secondary/40 p-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
</button>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="att.progressPercent"
></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>{{ formatSpeed(att.speedBps) }}</span>
}
</div>
</div> </div>
<div class="flex items-center gap-2"> } @else {
@if (!att.isUploader) { <div class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4">
@if (!att.available) { <div class="flex items-start justify-between gap-3">
<div class="h-1.5 w-24 rounded bg-muted"> <div class="min-w-0 flex-1">
<div <div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
class="h-1.5 rounded bg-primary" <div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
[style.width.%]="att.progressPercent" <div
></div> class="mt-1 text-xs leading-relaxed"
[class.opacity-80]="!att.requestError"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.mediaStatusText }}
</div> </div>
<div class="flex items-center gap-2 text-xs text-muted-foreground"> </div>
<span>{{ att.progressPercent | number: '1.0-0' }}%</span> <button
@if (att.speedBps) { (click)="requestAttachment(att)"
<span>• {{ formatSpeed(att.speedBps) }}</span> class="shrink-0 rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
>
{{ att.mediaActionLabel }}
</button>
</div>
</div>
}
} @else {
<div class="rounded-md border border-border bg-secondary/40 p-2">
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
</div>
<div class="flex items-center gap-2">
@if (!att.isUploader) {
@if (!att.available) {
<div class="h-1.5 w-24 rounded bg-muted">
<div
class="h-1.5 rounded bg-primary"
[style.width.%]="att.progressPercent"
></div>
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>• {{ formatSpeed(att.speedBps) }}</span>
}
</div>
@if (!(att.receivedBytes || 0)) {
<button
class="rounded bg-secondary px-2 py-1 text-xs text-foreground"
(click)="requestAttachment(att)"
>
{{ att.requestError ? 'Retry' : 'Request' }}
</button>
} @else {
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
</button>
} }
</div>
@if (!(att.receivedBytes || 0)) {
<button
class="rounded bg-secondary px-2 py-1 text-xs text-foreground"
(click)="requestAttachment(att)"
>
{{ att.requestError ? 'Retry' : 'Request' }}
</button>
} @else { } @else {
<button <button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground" class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
(click)="cancelAttachment(att)" (click)="downloadAttachment(att)"
> >
Cancel Download
</button> </button>
} }
} @else { } @else {
<button <div class="text-xs text-muted-foreground">Shared from your device</div>
class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
(click)="downloadAttachment(att)"
>
Download
</button>
} }
} @else { </div>
<div class="text-xs text-muted-foreground">Shared from your device</div>
}
</div> </div>
@if (!att.available && att.requestError) {
<div
class="mt-2 w-full rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 text-xs leading-relaxed text-destructive"
>
{{ att.requestError }}
</div>
}
</div> </div>
@if (!att.available && att.requestError) { }
<div
class="mt-2 w-full rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 text-xs leading-relaxed text-destructive"
>
{{ att.requestError }}
</div>
}
</div>
} }
} </div>
</div> }
} }
} }
@if (msg.reactions.length > 0) { @if (!msg.isDeleted && msg.reactions.length > 0) {
<div class="mt-2 flex flex-wrap gap-1"> <div class="mt-2 flex flex-wrap gap-1">
@for (reaction of getGroupedReactions(); track reaction.emoji) { @for (reaction of getGroupedReactions(); track reaction.emoji) {
<button <button

View File

@@ -33,7 +33,10 @@ import {
MAX_AUTO_SAVE_SIZE_BYTES MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../core/services/attachment.service'; } from '../../../../../core/services/attachment.service';
import { KlipyService } from '../../../../../core/services/klipy.service'; import { KlipyService } from '../../../../../core/services/klipy.service';
import { Message } from '../../../../../core/models'; import {
DELETED_MESSAGE_CONTENT,
Message
} from '../../../../../core/models';
import { import {
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
ChatVideoPlayerComponent, ChatVideoPlayerComponent,
@@ -143,6 +146,7 @@ export class ChatMessageItemComponent {
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>(); readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly commonEmojis = COMMON_EMOJIS; readonly commonEmojis = COMMON_EMOJIS;
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
readonly isEditing = signal(false); readonly isEditing = signal(false);
readonly showEmojiPicker = signal(false); readonly showEmojiPicker = signal(false);

View File

@@ -17,7 +17,7 @@ import {
} from 'rxjs'; } from 'rxjs';
import { mergeMap } from 'rxjs/operators'; import { mergeMap } from 'rxjs/operators';
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { Message } from '../../core/models/index'; import { DELETED_MESSAGE_CONTENT, Message } from '../../core/models/index';
import type { DebuggingService } from '../../core/services'; import type { DebuggingService } from '../../core/services';
import { DatabaseService } from '../../core/services/database.service'; import { DatabaseService } from '../../core/services/database.service';
import { trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers'; import { trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
@@ -221,6 +221,14 @@ async function processSyncBatch(
for (const incoming of event.messages as Message[]) { for (const incoming of event.messages as Message[]) {
const { message, changed } = await mergeIncomingMessage(incoming, db); const { message, changed } = await mergeIncomingMessage(incoming, db);
if (incoming.isDeleted) {
try {
await attachments.deleteForMessage(incoming.id);
} catch (error) {
throw new Error(`Failed to delete attachments for message ${incoming.id} during sync: ${message.id}. Error: ${error}`);
}
}
if (changed) if (changed)
toUpsert.push(message); toUpsert.push(message);
} }
@@ -324,15 +332,35 @@ function handleMessageEdited(
/** Applies a remote message deletion to the local DB and store. */ /** Applies a remote message deletion to the local DB and store. */
function handleMessageDeleted( function handleMessageDeleted(
event: any, event: any,
{ db, debugging }: IncomingMessageContext { db, debugging, attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!event.messageId) if (!event.messageId)
return EMPTY; return EMPTY;
const deletedAt = typeof event.deletedAt === 'number'
? event.deletedAt
: Date.now();
trackBackgroundOperation( trackBackgroundOperation(
db.deleteMessage(event.messageId), db.updateMessage(event.messageId, {
content: DELETED_MESSAGE_CONTENT,
editedAt: deletedAt,
isDeleted: true
}),
debugging, debugging,
'Failed to persist incoming message deletion', 'Failed to persist incoming message deletion',
{
deletedBy: event.deletedBy ?? null,
deletedAt,
fromPeerId: event.fromPeerId ?? null,
messageId: event.messageId
}
);
trackBackgroundOperation(
attachments.deleteForMessage(event.messageId),
debugging,
'Failed to delete incoming message attachments',
{ {
deletedBy: event.deletedBy ?? null, deletedBy: event.deletedBy ?? null,
fromPeerId: event.fromPeerId ?? null, fromPeerId: event.fromPeerId ?? null,
@@ -498,25 +526,18 @@ function handleSyncRequest(
/** Merges a full message dump from a peer into the local DB and store. */ /** Merges a full message dump from a peer into the local DB and store. */
function handleSyncFull( function handleSyncFull(
event: any, event: any,
{ db, debugging }: IncomingMessageContext { db, attachments }: IncomingMessageContext
): Observable<Action> { ): Observable<Action> {
if (!event.messages || !Array.isArray(event.messages)) if (!event.messages || !Array.isArray(event.messages))
return EMPTY; return EMPTY;
event.messages.forEach((msg: Message) => { return from(processSyncBatch(event, db, attachments)).pipe(
trackBackgroundOperation( mergeMap((toUpsert) =>
db.saveMessage(msg), toUpsert.length > 0
debugging, ? of(MessagesActions.syncMessages({ messages: toUpsert }))
'Failed to persist full-sync message batch item', : EMPTY
{ )
fromPeerId: event.fromPeerId ?? null, );
messageId: msg.id,
roomId: msg.roomId
}
);
});
return of(MessagesActions.syncMessages({ messages: event.messages }));
} }
/** Map of event types to their handler functions. */ /** Map of event types to their handler functions. */

View File

@@ -37,7 +37,11 @@ import { DebuggingService } from '../../core/services';
import { WebRTCService } from '../../core/services/webrtc.service'; import { WebRTCService } from '../../core/services/webrtc.service';
import { TimeSyncService } from '../../core/services/time-sync.service'; import { TimeSyncService } from '../../core/services/time-sync.service';
import { AttachmentService } from '../../core/services/attachment.service'; import { AttachmentService } from '../../core/services/attachment.service';
import { Message, Reaction } from '../../core/models/index'; import {
DELETED_MESSAGE_CONTENT,
Message,
Reaction
} from '../../core/models/index';
import { hydrateMessages } from './messages.helpers'; import { hydrateMessages } from './messages.helpers';
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers'; import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
@@ -192,14 +196,30 @@ export class MessagesEffects {
return of(MessagesActions.deleteMessageFailure({ error: 'Cannot delete others messages' })); return of(MessagesActions.deleteMessageFailure({ error: 'Cannot delete others messages' }));
} }
const deletedAt = this.timeSync.now();
this.trackBackgroundOperation( this.trackBackgroundOperation(
this.db.updateMessage(messageId, { isDeleted: true }), this.db.updateMessage(messageId, {
content: DELETED_MESSAGE_CONTENT,
editedAt: deletedAt,
isDeleted: true
}),
'Failed to persist message deletion', 'Failed to persist message deletion',
{
deletedAt,
messageId
}
);
this.trackBackgroundOperation(
this.attachments.deleteForMessage(messageId),
'Failed to delete message attachments',
{ messageId } { messageId }
); );
this.webrtc.broadcastMessage({ type: 'message-deleted', this.webrtc.broadcastMessage({ type: 'message-deleted',
messageId }); messageId,
deletedAt });
return of(MessagesActions.deleteMessageSuccess({ messageId })); return of(MessagesActions.deleteMessageSuccess({ messageId }));
}), }),
@@ -230,9 +250,25 @@ export class MessagesEffects {
return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' })); return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' }));
} }
const deletedAt = this.timeSync.now();
this.trackBackgroundOperation( this.trackBackgroundOperation(
this.db.updateMessage(messageId, { isDeleted: true }), this.db.updateMessage(messageId, {
content: DELETED_MESSAGE_CONTENT,
editedAt: deletedAt,
isDeleted: true
}),
'Failed to persist admin message deletion', 'Failed to persist admin message deletion',
{
deletedBy: currentUser.id,
deletedAt,
messageId
}
);
this.trackBackgroundOperation(
this.attachments.deleteForMessage(messageId),
'Failed to delete admin-deleted message attachments',
{ {
deletedBy: currentUser.id, deletedBy: currentUser.id,
messageId messageId
@@ -241,7 +277,8 @@ export class MessagesEffects {
this.webrtc.broadcastMessage({ type: 'message-deleted', this.webrtc.broadcastMessage({ type: 'message-deleted',
messageId, messageId,
deletedBy: currentUser.id }); deletedBy: currentUser.id,
deletedAt });
return of(MessagesActions.deleteMessageSuccess({ messageId })); return of(MessagesActions.deleteMessageSuccess({ messageId }));
}), }),

View File

@@ -5,6 +5,7 @@
* and reuse across effects and handler files. * and reuse across effects and handler files.
*/ */
import { Message } from '../../core/models/index'; import { Message } from '../../core/models/index';
import { DELETED_MESSAGE_CONTENT } from '../../core/models/index';
import { DatabaseService } from '../../core/services/database.service'; import { DatabaseService } from '../../core/services/database.service';
/** Maximum number of recent messages to include in sync inventories. */ /** Maximum number of recent messages to include in sync inventories. */
@@ -49,11 +50,25 @@ export function chunkArray<T>(items: T[], size: number): T[][] {
return chunks; return chunks;
} }
function normaliseDeletedMessage(message: Message): Message {
if (!message.isDeleted)
return message;
return {
...message,
content: DELETED_MESSAGE_CONTENT,
reactions: []
};
}
/** Hydrates a single message with its reactions from the database. */ /** Hydrates a single message with its reactions from the database. */
export async function hydrateMessage( export async function hydrateMessage(
msg: Message, msg: Message,
db: DatabaseService db: DatabaseService
): Promise<Message> { ): Promise<Message> {
if (msg.isDeleted)
return normaliseDeletedMessage(msg);
const reactions = await db.getReactionsForMessage(msg.id); const reactions = await db.getReactionsForMessage(msg.id);
return reactions.length > 0 ? { ...msg, return reactions.length > 0 ? { ...msg,
@@ -82,6 +97,15 @@ export async function buildInventoryItem(
db: DatabaseService, db: DatabaseService,
attachmentCountOverride?: number attachmentCountOverride?: number
): Promise<InventoryItem> { ): Promise<InventoryItem> {
if (msg.isDeleted) {
return {
id: msg.id,
ts: getMessageTimestamp(msg),
rc: 0,
ac: 0
};
}
const reactions = await db.getReactionsForMessage(msg.id); const reactions = await db.getReactionsForMessage(msg.id);
const attachments = const attachments =
attachmentCountOverride === undefined attachmentCountOverride === undefined
@@ -104,6 +128,15 @@ export async function buildLocalInventoryMap(
await Promise.all( await Promise.all(
messages.map(async (msg) => { messages.map(async (msg) => {
if (msg.isDeleted) {
map.set(msg.id, {
ts: getMessageTimestamp(msg),
rc: 0,
ac: 0
});
return;
}
const reactions = await db.getReactionsForMessage(msg.id); const reactions = await db.getReactionsForMessage(msg.id);
const attachmentCountOverride = attachmentCountOverrides?.get(msg.id); const attachmentCountOverride = attachmentCountOverrides?.get(msg.id);
const attachments = const attachments =
@@ -161,14 +194,19 @@ export async function mergeIncomingMessage(
const existing = await db.getMessageById(incoming.id); const existing = await db.getMessageById(incoming.id);
const existingTs = existing ? getMessageTimestamp(existing) : -1; const existingTs = existing ? getMessageTimestamp(existing) : -1;
const incomingTs = getMessageTimestamp(incoming); const incomingTs = getMessageTimestamp(incoming);
const isNewer = !existing || incomingTs > existingTs; const isDeletedStateNewer =
!!existing &&
incomingTs === existingTs &&
incoming.isDeleted &&
!existing.isDeleted;
const isNewer = !existing || incomingTs > existingTs || isDeletedStateNewer;
if (isNewer) { if (isNewer) {
await db.saveMessage(incoming); await db.saveMessage(incoming);
} }
// Persist incoming reactions (deduped by the DB layer) // Persist incoming reactions (deduped by the DB layer)
const incomingReactions = incoming.reactions ?? []; const incomingReactions = incoming.isDeleted ? [] : incoming.reactions ?? [];
for (const reaction of incomingReactions) { for (const reaction of incomingReactions) {
await db.saveReaction(reaction); await db.saveReaction(reaction);
@@ -177,14 +215,22 @@ export async function mergeIncomingMessage(
const changed = isNewer || incomingReactions.length > 0; const changed = isNewer || incomingReactions.length > 0;
if (changed) { if (changed) {
const reactions = await db.getReactionsForMessage(incoming.id);
const baseMessage = isNewer ? incoming : existing; const baseMessage = isNewer ? incoming : existing;
if (!baseMessage) { if (!baseMessage) {
return { message: incoming, return { message: normaliseDeletedMessage(incoming),
changed }; changed };
} }
if (baseMessage.isDeleted) {
return {
message: normaliseDeletedMessage(baseMessage),
changed
};
}
const reactions = await db.getReactionsForMessage(incoming.id);
return { return {
message: { ...baseMessage, message: { ...baseMessage,
reactions }, reactions },
@@ -193,10 +239,10 @@ export async function mergeIncomingMessage(
} }
if (!existing) { if (!existing) {
return { message: incoming, return { message: normaliseDeletedMessage(incoming),
changed: false }; changed: false };
} }
return { message: existing, return { message: normaliseDeletedMessage(existing),
changed: false }; changed: false };
} }

View File

@@ -4,7 +4,10 @@ import {
EntityAdapter, EntityAdapter,
createEntityAdapter createEntityAdapter
} from '@ngrx/entity'; } from '@ngrx/entity';
import { Message } from '../../core/models/index'; import {
DELETED_MESSAGE_CONTENT,
Message
} from '../../core/models/index';
import { MessagesActions } from './messages.actions'; import { MessagesActions } from './messages.actions';
/** State shape for the messages feature slice, extending NgRx EntityState. */ /** State shape for the messages feature slice, extending NgRx EntityState. */
@@ -107,8 +110,11 @@ export const messagesReducer = createReducer(
messagesAdapter.updateOne( messagesAdapter.updateOne(
{ {
id: messageId, id: messageId,
changes: { isDeleted: true, changes: {
content: '[Message deleted]' } content: DELETED_MESSAGE_CONTENT,
isDeleted: true,
reactions: []
}
}, },
state state
) )