From edb46247968967e128951392861ad73c2a0579fb Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 14:33:33 -0400 Subject: [PATCH] feat(privacy): delete all my messages in a room (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Room Settings → General → Privacy: 'Your messages in this room' with a Delete all… flow. The confirm dialog first counts your events with a server-side sender-filtered /messages walk (live count), then asks to confirm with the number — typing the room name above 50 — and offers 'Leave the room afterwards'. Files are called out as not purged by a redaction. The job runs outside React (closing settings is fine): sequential redactEvent with 429 back-off, 404/already-redacted skipped, progress on the tile with Cancel, pending ids persisted per room so a reload shows Resume/Discard, a toast when done. State events are never touched; reactions, edits and thread replies you sent are included; encrypted rooms work the same (nothing is decrypted). Own events need no power level, so it is purely self-service. Unit tests cover candidate filtering, the server filter, pagination, 429/404 handling and cancel. Verified headless: 62 of bob's events (60 messages, a reaction, a thread reply) redacted in ~34 s while alice's 10 stayed; cancel at 17/40 → reload → Resume → 'Deleted 40 messages.' Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../general/DeleteMyMessages.tsx | 294 ++++++++++++++++++ .../features/common-settings/general/index.ts | 1 + .../room-settings/general/General.tsx | 5 + src/app/state/redactOwnMessagesJob.ts | 129 ++++++++ src/app/utils/redactOwnMessages.test.ts | 133 ++++++++ src/app/utils/redactOwnMessages.ts | 163 ++++++++++ 6 files changed, 725 insertions(+) create mode 100644 src/app/features/common-settings/general/DeleteMyMessages.tsx create mode 100644 src/app/state/redactOwnMessagesJob.ts create mode 100644 src/app/utils/redactOwnMessages.test.ts create mode 100644 src/app/utils/redactOwnMessages.ts diff --git a/src/app/features/common-settings/general/DeleteMyMessages.tsx b/src/app/features/common-settings/general/DeleteMyMessages.tsx new file mode 100644 index 000000000..0a6af069f --- /dev/null +++ b/src/app/features/common-settings/general/DeleteMyMessages.tsx @@ -0,0 +1,294 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + Box, + Button, + Checkbox, + color, + config, + Dialog, + Header, + Icon, + IconButton, + Icons, + Input, + Overlay, + OverlayBackdrop, + OverlayCenter, + Spinner, + Text, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useAtomValue, useSetAtom, useStore } from 'jotai'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../../room-settings/styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { useRoom } from '../../../hooks/useRoom'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useModalStyle } from '../../../hooks/useModalStyle'; +import { stopPropagation } from '../../../utils/keyboard'; +import { toastQueueAtom } from '../../../state/toast'; +import { + cancelRedactJob, + clearRedactJob, + hydrateRedactJob, + redactJobsAtom, + runRedactJob, +} from '../../../state/redactOwnMessagesJob'; +import { collectOwnEventIds } from '../../../utils/redactOwnMessages'; + +const TYPE_TO_CONFIRM_ABOVE = 50; + +type ConfirmDialogProps = { + onStart: (leaveAfter: boolean) => void; + requestClose: () => void; +}; + +/** + * Scan first (live count), then confirm with the number — typing the room name + * for > 50 — and an optional "leave the room afterwards". + */ +function ConfirmDialog({ onStart, requestClose }: ConfirmDialogProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const modalStyle = useModalStyle(480); + const [count, setCount] = useState(null); + const [scanError, setScanError] = useState(); + const [typed, setTyped] = useState(''); + const [leaveAfter, setLeaveAfter] = useState(false); + + useEffect(() => { + const ctrl = new AbortController(); + setCount(null); + collectOwnEventIds(mx, room.roomId, mx.getSafeUserId(), (n) => setCount(n), ctrl.signal) + .then((ids) => setCount(ids.length)) + .catch((e) => { + if (!ctrl.signal.aborted) setScanError(e instanceof Error ? e.message : String(e)); + }); + return () => ctrl.abort(); + }, [mx, room.roomId]); + + const scanning = count === null && !scanError; + const needsTyping = (count ?? 0) > TYPE_TO_CONFIRM_ABOVE; + const roomName = room.name ?? room.roomId; + const canStart = + !scanning && !scanError && (count ?? 0) > 0 && (!needsTyping || typed.trim() === roomName); + + return ( + }> + + + +
+ + + Delete all your messages + + + + + +
+ + {scanning && ( + + + + Counting your messages in {roomName}…{' '} + {count ? `${Number(count).toLocaleString()} so far` : ''} + + + )} + {scanError && ( + + Couldn't count your messages: {scanError} + + )} + {!scanning && !scanError && ( + <> + + Redact {(count ?? 0).toLocaleString()} messages you sent in{' '} + {roomName}? This cannot be undone. Redactions are visible to others as + “message deleted”, and files you uploaded stay on the server until + an admin purges them. + + {needsTyping && ( + + Type the room name to confirm + setTyped(e.currentTarget.value)} + placeholder={roomName} + aria-label="Room name confirmation" + /> + + )} + + setLeaveAfter((v) => !v)} + aria-label="Leave the room afterwards" + /> + Leave the room afterwards + + + )} + + + + + +
+
+
+
+ ); +} + +/** + * [Gitea #169] Room Settings tile: "Your messages in this room" with a + * Delete all… flow. The job runs outside React (closing settings is fine), + * shows its progress here, persists so a reload can resume, and toasts when done. + */ +export function DeleteMyMessages() { + const mx = useMatrixClient(); + const room = useRoom(); + const store = useStore(); + const setToast = useSetAtom(toastQueueAtom); + const jobs = useAtomValue(redactJobsAtom); + const job = jobs[room.roomId]; + const [confirm, setConfirm] = useState(false); + + // A reload mid-job: offer to resume. + useEffect(() => { + if (!jobs[room.roomId]) hydrateRedactJob(store, room.roomId); + }, [store, room.roomId, jobs]); + + const start = useCallback( + (leaveAfter: boolean, resume?: boolean) => { + setConfirm(false); + const resumeState = resume ? jobs[room.roomId] : undefined; + const name = room.name ?? room.roomId; + runRedactJob( + store, + mx, + room.roomId, + { + leaveAfter, + onDone: (s) => + setToast({ + id: `redact-done-${room.roomId}`, + displayName: 'Lotus Chat', + body: `Deleted ${s.redacted.toLocaleString()} of your messages in ${name}${s.skipped ? ` (${s.skipped} already gone)` : ''}.`, + roomName: name, + roomId: room.roomId, + }), + }, + resumeState, + ); + }, + [store, mx, room, jobs, setToast], + ); + + const running = job && (job.phase === 'collecting' || job.phase === 'redacting'); + const total = job ? job.redacted + job.skipped + job.pending.length : 0; + const done = job ? job.redacted + job.skipped : 0; + + let status: React.ReactNode = 'Redact everything you sent here — no moderator needed.'; + if (job?.phase === 'collecting') status = `Counting… ${job.found.toLocaleString()} found`; + else if (job?.phase === 'redacting') + status = `Deleting… ${done.toLocaleString()} / ${total.toLocaleString()}`; + else if (job?.phase === 'error') + status = `Stopped: ${job.error}. ${job.pending.length} left — you can resume.`; + else if (job?.phase === 'cancelled' && job.pending.length > 0) + status = `Cancelled with ${job.pending.length.toLocaleString()} left — you can resume.`; + else if (job?.phase === 'done') status = `Deleted ${job.redacted.toLocaleString()} messages.`; + + const resumable = job && !running && job.pending.length > 0; + + return ( + + + {running && ( + + )} + {resumable && ( + <> + + + + )} + {!running && !resumable && ( + + )} + + } + /> + {confirm && ( + start(leave)} requestClose={() => setConfirm(false)} /> + )} + + ); +} diff --git a/src/app/features/common-settings/general/index.ts b/src/app/features/common-settings/general/index.ts index 98f76cbc7..4d7fdd2fb 100644 --- a/src/app/features/common-settings/general/index.ts +++ b/src/app/features/common-settings/general/index.ts @@ -9,3 +9,4 @@ export * from './RoomRetention'; export * from './RoomShareInvite'; export * from './RoomUpgrade'; export * from './RoomVoiceLimit'; +export * from './DeleteMyMessages'; diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx index 1422edb2c..140517172 100644 --- a/src/app/features/room-settings/general/General.tsx +++ b/src/app/features/room-settings/general/General.tsx @@ -15,6 +15,7 @@ import { RoomRetention, RoomShareInvite, RoomUpgrade, + DeleteMyMessages, RoomVoiceLimit, } from '../../common-settings/general'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; @@ -79,6 +80,10 @@ export function General({ requestClose }: GeneralProps) { Advanced Options + + Privacy + + diff --git a/src/app/state/redactOwnMessagesJob.ts b/src/app/state/redactOwnMessagesJob.ts new file mode 100644 index 000000000..4abf7f7cc --- /dev/null +++ b/src/app/state/redactOwnMessagesJob.ts @@ -0,0 +1,129 @@ +import { atom, createStore } from 'jotai'; +import { MatrixClient } from 'matrix-js-sdk'; +import { + collectOwnEventIds, + loadRedactJob, + redactPending, + RedactJobState, + saveRedactJob, +} from '../utils/redactOwnMessages'; + +/** + * [Gitea #169] One background "delete all my messages" job per room, kept + * outside React so it survives the settings dialog closing. State is mirrored + * into `redactJobsAtom` for the UI and persisted for resume-after-reload. + */ +export const redactJobsAtom = atom>({}); + +const controllers = new Map(); + +type Store = ReturnType; + +const publish = (store: Store, state: RedactJobState) => { + saveRedactJob(state); + store.set(redactJobsAtom, (prev) => ({ ...prev, [state.roomId]: { ...state } })); +}; + +export const isRedactJobRunning = (roomId: string): boolean => controllers.has(roomId); + +export const cancelRedactJob = (roomId: string): void => { + controllers.get(roomId)?.abort(); +}; + +export const clearRedactJob = (store: Store, roomId: string): void => { + saveRedactJob({ + roomId, + phase: 'cancelled', + pending: [], + found: 0, + redacted: 0, + skipped: 0, + leaveAfter: false, + }); + store.set(redactJobsAtom, (prev) => { + const next = { ...prev }; + delete next[roomId]; + return next; + }); +}; + +/** Pick up a persisted, unfinished job (e.g. after a reload). */ +export const hydrateRedactJob = (store: Store, roomId: string): RedactJobState | null => { + const saved = loadRedactJob(roomId); + if (saved && saved.pending.length > 0 && saved.phase !== 'done') { + // Nothing is running after a reload — surface it as paused/resumable. + const state: RedactJobState = { ...saved, phase: 'cancelled' }; + store.set(redactJobsAtom, (prev) => ({ ...prev, [roomId]: state })); + return state; + } + return null; +}; + +export type RedactJobOptions = { + leaveAfter: boolean; + /** Called once everything is redacted (and the room left, if asked). */ + onDone?: (state: RedactJobState) => void; +}; + +/** + * Start (or resume) the job. Resolves when it stops for any reason; errors + * and cancellation are reported through the state, not thrown. + */ +export async function runRedactJob( + store: Store, + mx: MatrixClient, + roomId: string, + opts: RedactJobOptions, + resume?: RedactJobState, +): Promise { + if (controllers.has(roomId)) return store.get(redactJobsAtom)[roomId]; + const ctrl = new AbortController(); + controllers.set(roomId, ctrl); + const state: RedactJobState = resume ?? { + roomId, + phase: 'collecting', + pending: [], + found: 0, + redacted: 0, + skipped: 0, + leaveAfter: opts.leaveAfter, + }; + state.leaveAfter = opts.leaveAfter; + publish(store, state); + try { + if (!resume) { + const ids = await collectOwnEventIds( + mx, + roomId, + mx.getSafeUserId(), + (found) => { + state.found = found; + publish(store, state); + }, + ctrl.signal, + ); + state.pending = ids; + state.found = ids.length; + } + state.phase = 'redacting'; + publish(store, state); + await redactPending(mx, state, (s) => publish(store, s), ctrl.signal); + state.phase = 'done'; + if (state.leaveAfter) { + await mx.leave(roomId); + } + publish(store, state); + opts.onDone?.(state); + } catch (e) { + if ((e as { name?: string })?.name === 'AbortError') { + state.phase = 'cancelled'; + } else { + state.phase = 'error'; + state.error = e instanceof Error ? e.message : String(e); + } + publish(store, state); + } finally { + controllers.delete(roomId); + } + return state; +} diff --git a/src/app/utils/redactOwnMessages.test.ts b/src/app/utils/redactOwnMessages.test.ts new file mode 100644 index 000000000..aacb0039e --- /dev/null +++ b/src/app/utils/redactOwnMessages.test.ts @@ -0,0 +1,133 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { MatrixClient, MatrixError } from 'matrix-js-sdk'; +import { + collectOwnEventIds, + isRedactableOwnEvent, + ownMessagesFilter, + redactPending, + RedactJobState, +} from './redactOwnMessages'; + +(globalThis as { localStorage?: unknown }).localStorage = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, +}; + +test('state events, redactions and already-redacted events are not candidates', () => { + assert.equal(isRedactableOwnEvent({ type: 'm.room.message', content: { body: 'x' } }), true); + assert.equal(isRedactableOwnEvent({ type: 'm.reaction', content: { 'm.relates_to': {} } }), true); + assert.equal( + isRedactableOwnEvent({ + type: 'm.room.member', + state_key: '@me', + content: { membership: 'join' }, + }), + false, + ); + assert.equal(isRedactableOwnEvent({ type: 'm.room.redaction', content: {} }), false); + assert.equal( + isRedactableOwnEvent({ + type: 'm.room.message', + content: {}, + unsigned: { redacted_because: {} }, + }), + false, + ); + assert.equal(isRedactableOwnEvent({ type: 'm.room.message', content: {} }), false); +}); + +test('the server-side filter asks only for my events', () => { + const f = ownMessagesFilter('@me:hs'); + assert.deepEqual(f.getRoomTimelineFilterComponent()?.toJSON(), { senders: ['@me:hs'] }); +}); + +test('collect paginates until the end token stops moving and keeps only my redactable events', async () => { + const pages: Record = { + start: { + chunk: [ + { event_id: '$1', sender: '@me:hs', type: 'm.room.message', content: { body: 'a' } }, + { event_id: '$2', sender: '@other:hs', type: 'm.room.message', content: { body: 'b' } }, + ], + end: 't1', + }, + t1: { + chunk: [ + { + event_id: '$3', + sender: '@me:hs', + type: 'm.room.member', + state_key: '@me:hs', + content: {}, + }, + { event_id: '$4', sender: '@me:hs', type: 'm.sticker', content: { url: 'mxc://x' } }, + ], + end: 't2', + }, + t2: { chunk: [], end: 't2' }, + }; + const mx = { + createMessagesRequest: async (_r: string, from: string | null) => pages[from ?? 'start'], + } as unknown as MatrixClient; + const progress: number[] = []; + const ids = await collectOwnEventIds(mx, '!r:hs', '@me:hs', (n) => progress.push(n)); + assert.deepEqual(ids, ['$1', '$4']); + assert.deepEqual(progress, [1, 2, 2]); +}); + +test('redactPending is sequential, skips 404s, honours 429 and persists progress', async () => { + const calls: string[] = []; + let first429 = true; + const mx = { + redactEvent: async (_r: string, id: string) => { + calls.push(id); + if (id === '$gone') throw new MatrixError({ errcode: 'M_NOT_FOUND', error: 'nope' }, 404); + if (id === '$slow' && first429) { + first429 = false; + throw new MatrixError( + { errcode: 'M_LIMIT_EXCEEDED', error: 'slow', retry_after_ms: 5 }, + 429, + ); + } + return { event_id: '$red' }; + }, + } as unknown as MatrixClient; + const state: RedactJobState = { + roomId: '!r:hs', + phase: 'redacting', + pending: ['$a', '$gone', '$slow'], + found: 3, + redacted: 0, + skipped: 0, + leaveAfter: false, + }; + const seen: number[] = []; + await redactPending(mx, state, (s) => seen.push(s.pending.length)); + assert.deepEqual(calls, ['$a', '$gone', '$slow', '$slow']); + assert.equal(state.redacted, 2); + assert.equal(state.skipped, 1); + assert.deepEqual(seen, [2, 1, 0]); +}); + +test('cancel aborts between events', async () => { + const ctrl = new AbortController(); + const mx = { + redactEvent: async () => { + ctrl.abort(); + return {}; + }, + } as unknown as MatrixClient; + const state: RedactJobState = { + roomId: '!r:hs', + phase: 'redacting', + pending: ['$a', '$b'], + found: 2, + redacted: 0, + skipped: 0, + leaveAfter: false, + }; + await assert.rejects(() => redactPending(mx, state, () => undefined, ctrl.signal), /cancelled/); + assert.equal(state.redacted, 1); + assert.deepEqual(state.pending, ['$b']); +}); diff --git a/src/app/utils/redactOwnMessages.ts b/src/app/utils/redactOwnMessages.ts new file mode 100644 index 000000000..d288737de --- /dev/null +++ b/src/app/utils/redactOwnMessages.ts @@ -0,0 +1,163 @@ +import { Direction, Filter, MatrixClient, MatrixError } from 'matrix-js-sdk'; + +// matrix-js-sdk keeps its IMessagesResponse private to client.ts. +type MessagesResponse = Awaited>; + +/** + * [Gitea #169] "Delete all my messages in this room": self-service redaction of + * every non-state event you sent. Two phases — collect (server-side + * sender-filtered /messages pagination) and redact (sequential, honours 429). + * Progress is persisted so a reload can resume; the mxc media behind + * attachments is NOT purged by a redaction (that is the server admin's job). + */ + +export type RedactJobPhase = 'collecting' | 'redacting' | 'done' | 'cancelled' | 'error'; + +export type RedactJobState = { + roomId: string; + phase: RedactJobPhase; + /** Event ids still to redact (persisted so a reload can resume). */ + pending: string[]; + found: number; + redacted: number; + skipped: number; + error?: string; + leaveAfter: boolean; +}; + +const STORAGE_PREFIX = 'lotus-redact-job-'; + +export const loadRedactJob = (roomId: string): RedactJobState | null => { + try { + const raw = localStorage.getItem(STORAGE_PREFIX + roomId); + if (!raw) return null; + const parsed = JSON.parse(raw) as RedactJobState; + return Array.isArray(parsed.pending) ? parsed : null; + } catch { + return null; + } +}; + +export const saveRedactJob = (state: RedactJobState): void => { + try { + // Only a finished (or discarded) job is forgotten; a cancelled/failed one + // keeps its pending list so it can be resumed after a reload. + if (state.phase === 'done' || state.pending.length === 0) { + localStorage.removeItem(STORAGE_PREFIX + state.roomId); + } else { + localStorage.setItem(STORAGE_PREFIX + state.roomId, JSON.stringify(state)); + } + } catch { + // storage unavailable — the job still runs, it just can't resume + } +}; + +/** Event types that are "yours to delete"; state events never qualify. */ +export const isRedactableOwnEvent = (ev: { + type: string; + state_key?: string; + unsigned?: { redacted_because?: unknown }; + content?: Record; +}): boolean => { + if (typeof ev.state_key === 'string') return false; + if (ev.unsigned?.redacted_because) return false; + if (ev.type === 'm.room.redaction') return false; + // Already-redacted events come back with empty content. + if (ev.content && Object.keys(ev.content).length === 0) return false; + return true; +}; + +export const ownMessagesFilter = (userId: string): Filter => { + const filter = new Filter(userId); + filter.setDefinition({ + room: { + timeline: { senders: [userId], limit: 100 }, + }, + }); + return filter; +}; + +/** + * Walk the room history backwards with a server-side sender filter and return + * the ids of every event we can redact. Calls `onProgress(found)` per page. + */ +export async function collectOwnEventIds( + mx: MatrixClient, + roomId: string, + userId: string, + onProgress: (found: number) => void, + signal?: AbortSignal, +): Promise { + const ids: string[] = []; + const filter = ownMessagesFilter(userId); + let from: string | null = null; + for (;;) { + if (signal?.aborted) throw new DOMException('cancelled', 'AbortError'); + const token: string | null = from; + // eslint-disable-next-line no-await-in-loop + const res: MessagesResponse = await withRateLimit(() => + mx.createMessagesRequest(roomId, token, 100, Direction.Backward, filter), + ); + res.chunk.forEach((ev) => { + if (ev.sender === userId && ev.event_id && isRedactableOwnEvent(ev)) ids.push(ev.event_id); + }); + onProgress(ids.length); + if (!res.end || res.end === from || res.chunk.length === 0) break; + from = res.end; + } + return ids; +} + +const sleep = (ms: number) => + new Promise((r) => { + setTimeout(r, ms); + }); + +async function withRateLimit(op: () => Promise, attempts = 6): Promise { + let n = 0; + for (;;) { + try { + // eslint-disable-next-line no-await-in-loop + return await op(); + } catch (e) { + if (e instanceof MatrixError && e.httpStatus === 429 && n < attempts) { + n += 1; + // eslint-disable-next-line no-await-in-loop + await sleep(e.getRetryAfterMs() ?? Math.min(1000 * 2 ** n, 30_000)); + } else { + throw e; + } + } + } +} + +/** + * Redact `state.pending` one by one, mutating and persisting `state` as it + * goes so a reload resumes where it stopped. Missing / already-redacted + * events are skipped, not fatal. + */ +export async function redactPending( + mx: MatrixClient, + state: RedactJobState, + onProgress: (state: RedactJobState) => void, + signal?: AbortSignal, +): Promise { + while (state.pending.length > 0) { + if (signal?.aborted) throw new DOMException('cancelled', 'AbortError'); + const id = state.pending[0]; + try { + // eslint-disable-next-line no-await-in-loop + await withRateLimit(() => mx.redactEvent(state.roomId, id)); + state.redacted += 1; + } catch (e) { + if (e instanceof MatrixError && (e.errcode === 'M_NOT_FOUND' || e.httpStatus === 404)) { + state.skipped += 1; + } else { + throw e; + } + } + state.pending.shift(); + saveRedactJob(state); + onProgress(state); + } +}