feat(privacy): delete all my messages in a room (#169)
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m40s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 14:33:33 -04:00
co-authored by Claude Opus 5
parent d4420905e6
commit edb4624796
6 changed files with 725 additions and 0 deletions
+129
View File
@@ -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<Record<string, RedactJobState>>({});
const controllers = new Map<string, AbortController>();
type Store = ReturnType<typeof createStore>;
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<RedactJobState> {
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;
}