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 './RoomUpgrade';
|
||||
export * from './RoomVoiceLimit';
|
||||
export * from './DeleteMyMessages';
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RoomRetention,
|
||||
RoomShareInvite,
|
||||
RoomUpgrade,
|
||||
DeleteMyMessages,
|
||||
RoomVoiceLimit,
|
||||
} from '../../common-settings/general';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
@@ -79,6 +80,10 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<Text size="L400">Advanced Options</Text>
|
||||
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Privacy</Text>
|
||||
<DeleteMyMessages />
|
||||
</Box>
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
|
||||
Reference in New Issue
Block a user