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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user