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
+163
View File
@@ -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<ReturnType<MatrixClient['createMessagesRequest']>>;
/**
* [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<string, unknown>;
}): 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<string[]> {
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<void>((r) => {
setTimeout(r, ms);
});
async function withRateLimit<T>(op: () => Promise<T>, attempts = 6): Promise<T> {
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<void> {
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);
}
}