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
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:
@@ -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<string, { chunk: unknown[]; end?: string }> = {
|
||||
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']);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user