Messages now actually gets deleted
This commit is contained in:
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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: []
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -69,6 +69,9 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
} @else {
|
||||||
|
@if (msg.isDeleted) {
|
||||||
|
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="chat-markdown mt-1 break-words">
|
<div class="chat-markdown mt-1 break-words">
|
||||||
<remark
|
<remark
|
||||||
@@ -331,8 +334,9 @@
|
|||||||
</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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 }));
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user