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); } }