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,294 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
Dialog,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||||
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { toastQueueAtom } from '../../../state/toast';
|
||||||
|
import {
|
||||||
|
cancelRedactJob,
|
||||||
|
clearRedactJob,
|
||||||
|
hydrateRedactJob,
|
||||||
|
redactJobsAtom,
|
||||||
|
runRedactJob,
|
||||||
|
} from '../../../state/redactOwnMessagesJob';
|
||||||
|
import { collectOwnEventIds } from '../../../utils/redactOwnMessages';
|
||||||
|
|
||||||
|
const TYPE_TO_CONFIRM_ABOVE = 50;
|
||||||
|
|
||||||
|
type ConfirmDialogProps = {
|
||||||
|
onStart: (leaveAfter: boolean) => void;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan first (live count), then confirm with the number — typing the room name
|
||||||
|
* for > 50 — and an optional "leave the room afterwards".
|
||||||
|
*/
|
||||||
|
function ConfirmDialog({ onStart, requestClose }: ConfirmDialogProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
const [count, setCount] = useState<number | null>(null);
|
||||||
|
const [scanError, setScanError] = useState<string>();
|
||||||
|
const [typed, setTyped] = useState('');
|
||||||
|
const [leaveAfter, setLeaveAfter] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
setCount(null);
|
||||||
|
collectOwnEventIds(mx, room.roomId, mx.getSafeUserId(), (n) => setCount(n), ctrl.signal)
|
||||||
|
.then((ids) => setCount(ids.length))
|
||||||
|
.catch((e) => {
|
||||||
|
if (!ctrl.signal.aborted) setScanError(e instanceof Error ? e.message : String(e));
|
||||||
|
});
|
||||||
|
return () => ctrl.abort();
|
||||||
|
}, [mx, room.roomId]);
|
||||||
|
|
||||||
|
const scanning = count === null && !scanError;
|
||||||
|
const needsTyping = (count ?? 0) > TYPE_TO_CONFIRM_ABOVE;
|
||||||
|
const roomName = room.name ?? room.roomId;
|
||||||
|
const canStart =
|
||||||
|
!scanning && !scanError && (count ?? 0) > 0 && (!needsTyping || typed.trim() === roomName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: requestClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface" style={modalStyle}>
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text as="h2" size="H4">
|
||||||
|
Delete all your messages
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={requestClose} radii="300" aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
{scanning && (
|
||||||
|
<Box alignItems="Center" gap="200">
|
||||||
|
<Spinner size="200" />
|
||||||
|
<Text>
|
||||||
|
Counting your messages in <b>{roomName}</b>…{' '}
|
||||||
|
{count ? `${Number(count).toLocaleString()} so far` : ''}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{scanError && (
|
||||||
|
<Text style={{ color: color.Critical.Main }}>
|
||||||
|
Couldn't count your messages: {scanError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!scanning && !scanError && (
|
||||||
|
<>
|
||||||
|
<Text>
|
||||||
|
Redact <b>{(count ?? 0).toLocaleString()}</b> messages you sent in{' '}
|
||||||
|
<b>{roomName}</b>? This cannot be undone. Redactions are visible to others as
|
||||||
|
“message deleted”, and files you uploaded stay on the server until
|
||||||
|
an admin purges them.
|
||||||
|
</Text>
|
||||||
|
{needsTyping && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Type the room name to confirm</Text>
|
||||||
|
<Input
|
||||||
|
variant="Background"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
value={typed}
|
||||||
|
onChange={(e) => setTyped(e.currentTarget.value)}
|
||||||
|
placeholder={roomName}
|
||||||
|
aria-label="Room name confirmation"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box as="label" alignItems="Center" gap="200">
|
||||||
|
<Checkbox
|
||||||
|
variant="Critical"
|
||||||
|
checked={leaveAfter}
|
||||||
|
onClick={() => setLeaveAfter((v) => !v)}
|
||||||
|
aria-label="Leave the room afterwards"
|
||||||
|
/>
|
||||||
|
<Text>Leave the room afterwards</Text>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Box gap="200" justifyContent="End">
|
||||||
|
<Button variant="Secondary" fill="Soft" onClick={requestClose}>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
<Button variant="Critical" disabled={!canStart} onClick={() => onStart(leaveAfter)}>
|
||||||
|
<Text size="B400">Delete {count ? count.toLocaleString() : ''} messages</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #169] Room Settings tile: "Your messages in this room" with a
|
||||||
|
* Delete all… flow. The job runs outside React (closing settings is fine),
|
||||||
|
* shows its progress here, persists so a reload can resume, and toasts when done.
|
||||||
|
*/
|
||||||
|
export function DeleteMyMessages() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const store = useStore();
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
const jobs = useAtomValue(redactJobsAtom);
|
||||||
|
const job = jobs[room.roomId];
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
|
||||||
|
// A reload mid-job: offer to resume.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jobs[room.roomId]) hydrateRedactJob(store, room.roomId);
|
||||||
|
}, [store, room.roomId, jobs]);
|
||||||
|
|
||||||
|
const start = useCallback(
|
||||||
|
(leaveAfter: boolean, resume?: boolean) => {
|
||||||
|
setConfirm(false);
|
||||||
|
const resumeState = resume ? jobs[room.roomId] : undefined;
|
||||||
|
const name = room.name ?? room.roomId;
|
||||||
|
runRedactJob(
|
||||||
|
store,
|
||||||
|
mx,
|
||||||
|
room.roomId,
|
||||||
|
{
|
||||||
|
leaveAfter,
|
||||||
|
onDone: (s) =>
|
||||||
|
setToast({
|
||||||
|
id: `redact-done-${room.roomId}`,
|
||||||
|
displayName: 'Lotus Chat',
|
||||||
|
body: `Deleted ${s.redacted.toLocaleString()} of your messages in ${name}${s.skipped ? ` (${s.skipped} already gone)` : ''}.`,
|
||||||
|
roomName: name,
|
||||||
|
roomId: room.roomId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
resumeState,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[store, mx, room, jobs, setToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const running = job && (job.phase === 'collecting' || job.phase === 'redacting');
|
||||||
|
const total = job ? job.redacted + job.skipped + job.pending.length : 0;
|
||||||
|
const done = job ? job.redacted + job.skipped : 0;
|
||||||
|
|
||||||
|
let status: React.ReactNode = 'Redact everything you sent here — no moderator needed.';
|
||||||
|
if (job?.phase === 'collecting') status = `Counting… ${job.found.toLocaleString()} found`;
|
||||||
|
else if (job?.phase === 'redacting')
|
||||||
|
status = `Deleting… ${done.toLocaleString()} / ${total.toLocaleString()}`;
|
||||||
|
else if (job?.phase === 'error')
|
||||||
|
status = `Stopped: ${job.error}. ${job.pending.length} left — you can resume.`;
|
||||||
|
else if (job?.phase === 'cancelled' && job.pending.length > 0)
|
||||||
|
status = `Cancelled with ${job.pending.length.toLocaleString()} left — you can resume.`;
|
||||||
|
else if (job?.phase === 'done') status = `Deleted ${job.redacted.toLocaleString()} messages.`;
|
||||||
|
|
||||||
|
const resumable = job && !running && job.pending.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Your messages in this room"
|
||||||
|
description={status}
|
||||||
|
after={
|
||||||
|
<Box gap="200">
|
||||||
|
{running && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => cancelRedactJob(room.roomId)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{resumable && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => start(job.leaveAfter, true)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Resume</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => clearRedactJob(store, room.roomId)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Discard</Text>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!running && !resumable && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => setConfirm(true)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Delete all…</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{confirm && (
|
||||||
|
<ConfirmDialog onStart={(leave) => start(leave)} requestClose={() => setConfirm(false)} />
|
||||||
|
)}
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,3 +9,4 @@ export * from './RoomRetention';
|
|||||||
export * from './RoomShareInvite';
|
export * from './RoomShareInvite';
|
||||||
export * from './RoomUpgrade';
|
export * from './RoomUpgrade';
|
||||||
export * from './RoomVoiceLimit';
|
export * from './RoomVoiceLimit';
|
||||||
|
export * from './DeleteMyMessages';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
RoomRetention,
|
RoomRetention,
|
||||||
RoomShareInvite,
|
RoomShareInvite,
|
||||||
RoomUpgrade,
|
RoomUpgrade,
|
||||||
|
DeleteMyMessages,
|
||||||
RoomVoiceLimit,
|
RoomVoiceLimit,
|
||||||
} from '../../common-settings/general';
|
} from '../../common-settings/general';
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
@@ -79,6 +80,10 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<Text size="L400">Advanced Options</Text>
|
<Text size="L400">Advanced Options</Text>
|
||||||
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
|
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Privacy</Text>
|
||||||
|
<DeleteMyMessages />
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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