Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 83f2bf4

Browse files
authored
Improve remove recent messages dialog (#6114)
* Allow keeping state events when removing recent messages The remove recent messages dialog redacts most state events since they can be abuse vectors as well, however some users that see the option actually want to use it to only remove messages. This adds a checkbox option to do so. Signed-off-by: Robin Townsend <[email protected]> * Don't redact encryption events when removing recent messages Signed-off-by: Robin Townsend <[email protected]> * Show UserMenu spinner while removing recent messages This also generalizes the UserMenu spinner to work with other types of actions in the future. Signed-off-by: Robin Townsend <[email protected]> * Clarify remove recent messages warning Clarify that they are removed for everyone in the conversation, not just yourself. Signed-off-by: Robin Townsend <[email protected]> * Adjust copy and preserve state events by default * Redact messages in reverse chronological order Signed-off-by: Robin Townsend <[email protected]>
1 parent d8a939d commit 83f2bf4

File tree

7 files changed

+241
-100
lines changed

7 files changed

+241
-100
lines changed

res/css/_components.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
@import "./views/dialogs/_Analytics.scss";
7979
@import "./views/dialogs/_AnalyticsLearnMoreDialog.scss";
8080
@import "./views/dialogs/_BugReportDialog.scss";
81+
@import "./views/dialogs/_BulkRedactDialog.scss";
8182
@import "./views/dialogs/_ChangelogDialog.scss";
8283
@import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss";
8384
@import "./views/dialogs/_CommunityPrototypeInviteDialog.scss";
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
Copyright 2021 Robin Townsend <[email protected]>
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
.mx_BulkRedactDialog {
18+
.mx_Checkbox, .mx_BulkRedactDialog_checkboxMicrocopy {
19+
line-height: $font-20px;
20+
}
21+
22+
.mx_BulkRedactDialog_checkboxMicrocopy {
23+
margin-left: 26px;
24+
color: $secondary-content;
25+
}
26+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
Copyright 2021 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React, { useState } from 'react';
18+
import { logger } from "matrix-js-sdk/src/logger";
19+
import { MatrixClient } from 'matrix-js-sdk/src/client';
20+
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
21+
import { Room } from 'matrix-js-sdk/src/models/room';
22+
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
23+
import { EventType } from "matrix-js-sdk/src/@types/event";
24+
25+
import { _t } from '../../../languageHandler';
26+
import dis from "../../../dispatcher/dispatcher";
27+
import { Action } from "../../../dispatcher/actions";
28+
import { IDialogProps } from "../dialogs/IDialogProps";
29+
import BaseDialog from "../dialogs/BaseDialog";
30+
import InfoDialog from "../dialogs/InfoDialog";
31+
import DialogButtons from "../elements/DialogButtons";
32+
import StyledCheckbox from "../elements/StyledCheckbox";
33+
34+
interface IBulkRedactDialogProps extends IDialogProps {
35+
matrixClient: MatrixClient;
36+
room: Room;
37+
member: RoomMember;
38+
}
39+
40+
const BulkRedactDialog: React.FC<IBulkRedactDialogProps> = props => {
41+
const { matrixClient: cli, room, member, onFinished } = props;
42+
const [keepStateEvents, setKeepStateEvents] = useState(true);
43+
44+
let timeline = room.getLiveTimeline();
45+
let eventsToRedact = [];
46+
while (timeline) {
47+
eventsToRedact = [...eventsToRedact, ...timeline.getEvents().filter(event =>
48+
event.getSender() === member.userId &&
49+
!event.isRedacted() && !event.isRedaction() &&
50+
event.getType() !== EventType.RoomCreate &&
51+
// Don't redact ACLs because that'll obliterate the room
52+
// See https://github.com/matrix-org/synapse/issues/4042 for details.
53+
event.getType() !== EventType.RoomServerAcl &&
54+
// Redacting encryption events is equally bad
55+
event.getType() !== EventType.RoomEncryption,
56+
)];
57+
timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS);
58+
}
59+
60+
if (eventsToRedact.length === 0) {
61+
return <InfoDialog
62+
onFinished={onFinished}
63+
title={_t("No recent messages by %(user)s found", { user: member.name })}
64+
description={
65+
<div>
66+
<p>{ _t("Try scrolling up in the timeline to see if there are any earlier ones.") }</p>
67+
</div>
68+
}
69+
/>;
70+
} else {
71+
eventsToRedact = eventsToRedact.filter(event => !(keepStateEvents && event.isState()));
72+
const count = eventsToRedact.length;
73+
const user = member.name;
74+
75+
const redact = async () => {
76+
logger.info(`Started redacting recent ${count} messages for ${member.userId} in ${room.roomId}`);
77+
dis.dispatch({
78+
action: Action.BulkRedactStart,
79+
room_id: room.roomId,
80+
});
81+
82+
// Submitting a large number of redactions freezes the UI,
83+
// so first yield to allow to rerender after closing the dialog.
84+
await Promise.resolve();
85+
await Promise.all(eventsToRedact.reverse().map(async event => {
86+
try {
87+
await cli.redactEvent(room.roomId, event.getId());
88+
} catch (err) {
89+
// log and swallow errors
90+
logger.error("Could not redact", event.getId());
91+
logger.error(err);
92+
}
93+
}));
94+
95+
logger.info(`Finished redacting recent ${count} messages for ${member.userId} in ${room.roomId}`);
96+
dis.dispatch({
97+
action: Action.BulkRedactEnd,
98+
room_id: room.roomId,
99+
});
100+
};
101+
102+
return <BaseDialog
103+
className="mx_BulkRedactDialog"
104+
onFinished={onFinished}
105+
title={_t("Remove recent messages by %(user)s", { user })}
106+
contentId="mx_Dialog_content"
107+
>
108+
<div className="mx_Dialog_content" id="mx_Dialog_content">
109+
<p>{ _t("You are about to remove %(count)s messages by %(user)s. " +
110+
"This will remove them permanently for everyone in the conversation. " +
111+
"Do you wish to continue?", { count, user }) }</p>
112+
<p>{ _t("For a large amount of messages, this might take some time. " +
113+
"Please don't refresh your client in the meantime.") }</p>
114+
<StyledCheckbox
115+
checked={keepStateEvents}
116+
onChange={e => setKeepStateEvents(e.target.checked)}
117+
>
118+
{ _t("Preserve system messages") }
119+
</StyledCheckbox>
120+
<div className="mx_BulkRedactDialog_checkboxMicrocopy">
121+
{ _t("Uncheck if you also want to remove system messages on this user " +
122+
"(e.g. membership change, profile change…)") }
123+
</div>
124+
</div>
125+
<DialogButtons
126+
primaryButton={_t("Remove %(count)s messages", { count })}
127+
primaryButtonClass="danger"
128+
primaryDisabled={count === 0}
129+
onPrimaryButtonClick={() => { setImmediate(redact); onFinished(true); }}
130+
onCancel={() => onFinished(false)}
131+
/>
132+
</BaseDialog>;
133+
}
134+
};
135+
136+
export default BulkRedactDialog;

src/components/views/right_panel/UserInfo.tsx

Lines changed: 8 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import { ClientEvent, MatrixClient } from 'matrix-js-sdk/src/client';
2323
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
2424
import { User } from 'matrix-js-sdk/src/models/user';
2525
import { Room } from 'matrix-js-sdk/src/models/room';
26-
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
2726
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
2827
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
2928
import { EventType } from "matrix-js-sdk/src/@types/event";
@@ -60,11 +59,11 @@ import Spinner from "../elements/Spinner";
6059
import PowerSelector from "../elements/PowerSelector";
6160
import MemberAvatar from "../avatars/MemberAvatar";
6261
import PresenceLabel from "../rooms/PresenceLabel";
62+
import BulkRedactDialog from "../dialogs/BulkRedactDialog";
6363
import ShareDialog from "../dialogs/ShareDialog";
6464
import ErrorDialog from "../dialogs/ErrorDialog";
6565
import QuestionDialog from "../dialogs/QuestionDialog";
6666
import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog";
67-
import InfoDialog from "../dialogs/InfoDialog";
6867
import RoomAvatar from "../avatars/RoomAvatar";
6968
import RoomName from "../elements/RoomName";
7069
import { mediaFromMxc } from "../../../customisations/Media";
@@ -629,75 +628,14 @@ const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBas
629628
const RedactMessagesButton: React.FC<IBaseProps> = ({ member }) => {
630629
const cli = useContext(MatrixClientContext);
631630

632-
const onRedactAllMessages = async () => {
633-
const { roomId, userId } = member;
634-
const room = cli.getRoom(roomId);
635-
if (!room) {
636-
return;
637-
}
638-
let timeline = room.getLiveTimeline();
639-
let eventsToRedact = [];
640-
while (timeline) {
641-
eventsToRedact = timeline.getEvents().reduce((events, event) => {
642-
if (event.getSender() === userId && !event.isRedacted() && !event.isRedaction() &&
643-
event.getType() !== EventType.RoomCreate &&
644-
// Don't redact ACLs because that'll obliterate the room
645-
// See https://github.com/matrix-org/synapse/issues/4042 for details.
646-
event.getType() !== EventType.RoomServerAcl
647-
) {
648-
return events.concat(event);
649-
} else {
650-
return events;
651-
}
652-
}, eventsToRedact);
653-
timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS);
654-
}
655-
656-
const count = eventsToRedact.length;
657-
const user = member.name;
658-
659-
if (count === 0) {
660-
Modal.createTrackedDialog('No user messages found to remove', '', InfoDialog, {
661-
title: _t("No recent messages by %(user)s found", { user }),
662-
description:
663-
<div>
664-
<p>{ _t("Try scrolling up in the timeline to see if there are any earlier ones.") }</p>
665-
</div>,
666-
});
667-
} else {
668-
const { finished } = Modal.createTrackedDialog('Remove recent messages by user', '', QuestionDialog, {
669-
title: _t("Remove recent messages by %(user)s", { user }),
670-
description:
671-
<div>
672-
<p>{ _t("You are about to remove %(count)s messages by %(user)s. " +
673-
"This cannot be undone. Do you wish to continue?", { count, user }) }</p>
674-
<p>{ _t("For a large amount of messages, this might take some time. " +
675-
"Please don't refresh your client in the meantime.") }</p>
676-
</div>,
677-
button: _t("Remove %(count)s messages", { count }),
678-
});
679-
680-
const [confirmed] = await finished;
681-
if (!confirmed) {
682-
return;
683-
}
684-
685-
// Submitting a large number of redactions freezes the UI,
686-
// so first yield to allow to rerender after closing the dialog.
687-
await Promise.resolve();
631+
const onRedactAllMessages = () => {
632+
const room = cli.getRoom(member.roomId);
633+
if (!room) return;
688634

689-
logger.info(`Started redacting recent ${count} messages for ${user} in ${roomId}`);
690-
await Promise.all(eventsToRedact.map(async event => {
691-
try {
692-
await cli.redactEvent(roomId, event.getId());
693-
} catch (err) {
694-
// log and swallow errors
695-
logger.error("Could not redact", event.getId());
696-
logger.error(err);
697-
}
698-
}));
699-
logger.info(`Finished redacting recent ${count} messages for ${user} in ${roomId}`);
700-
}
635+
Modal.createTrackedDialog("Bulk Redact Dialog", "", BulkRedactDialog, {
636+
matrixClient: cli,
637+
room, member,
638+
});
701639
};
702640

703641
return <AccessibleButton className="mx_UserInfo_field mx_UserInfo_destructive" onClick={onRedactAllMessages}>

src/components/views/rooms/RoomListHeader.tsx

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -137,29 +137,50 @@ const PrototypeCommunityContextMenu = (props: ComponentProps<typeof SpaceContext
137137
</IconizedContextMenu>;
138138
};
139139

140-
const useJoiningRooms = (): Set<string> => {
140+
// Long-running actions that should trigger a spinner
141+
enum PendingActionType {
142+
JoinRoom,
143+
BulkRedact,
144+
}
145+
146+
const usePendingActions = (): Map<PendingActionType, Set<string>> => {
141147
const cli = useContext(MatrixClientContext);
142-
const [joiningRooms, setJoiningRooms] = useState(new Set<string>());
148+
const [actions, setActions] = useState(new Map<PendingActionType, Set<string>>());
149+
150+
const addAction = (type: PendingActionType, key: string) => {
151+
const keys = new Set(actions.get(type));
152+
keys.add(key);
153+
setActions(new Map(actions).set(type, keys));
154+
};
155+
const removeAction = (type: PendingActionType, key: string) => {
156+
const keys = new Set(actions.get(type));
157+
if (keys.delete(key)) {
158+
setActions(new Map(actions).set(type, keys));
159+
}
160+
};
161+
143162
useDispatcher(defaultDispatcher, payload => {
144163
switch (payload.action) {
145164
case Action.JoinRoom:
146-
setJoiningRooms(new Set(joiningRooms.add(payload.roomId)));
165+
addAction(PendingActionType.JoinRoom, payload.roomId);
147166
break;
148167
case Action.JoinRoomReady:
149168
case Action.JoinRoomError:
150-
if (joiningRooms.delete(payload.roomId)) {
151-
setJoiningRooms(new Set(joiningRooms));
152-
}
169+
removeAction(PendingActionType.JoinRoom, payload.roomId);
170+
break;
171+
case Action.BulkRedactStart:
172+
addAction(PendingActionType.BulkRedact, payload.roomId);
173+
break;
174+
case Action.BulkRedactEnd:
175+
removeAction(PendingActionType.BulkRedact, payload.roomId);
153176
break;
154177
}
155178
});
156-
useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) => {
157-
if (joiningRooms.delete(room.roomId)) {
158-
setJoiningRooms(new Set(joiningRooms));
159-
}
160-
});
179+
useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) =>
180+
removeAction(PendingActionType.JoinRoom, room.roomId),
181+
);
161182

162-
return joiningRooms;
183+
return actions;
163184
};
164185

165186
interface IProps {
@@ -179,7 +200,7 @@ const RoomListHeader = ({ spacePanelDisabled, onVisibilityChange }: IProps) => {
179200
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
180201
return SpaceStore.instance.allRoomsInHome;
181202
});
182-
const joiningRooms = useJoiningRooms();
203+
const pendingActions = usePendingActions();
183204

184205
const filterCondition = RoomListStore.instance.getFirstNameFilterCondition();
185206
const count = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => {
@@ -398,14 +419,17 @@ const RoomListHeader = ({ spacePanelDisabled, onVisibilityChange }: IProps) => {
398419
title = getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome);
399420
}
400421

401-
let pendingRoomJoinSpinner: JSX.Element;
402-
if (joiningRooms.size) {
403-
pendingRoomJoinSpinner = <TooltipTarget
404-
label={_t("Currently joining %(count)s rooms", { count: joiningRooms.size })}
405-
>
406-
<InlineSpinner />
407-
</TooltipTarget>;
408-
}
422+
const pendingActionSummary = [...pendingActions.entries()]
423+
.filter(([type, keys]) => keys.size > 0)
424+
.map(([type, keys]) => {
425+
switch (type) {
426+
case PendingActionType.JoinRoom:
427+
return _t("Currently joining %(count)s rooms", { count: keys.size });
428+
case PendingActionType.BulkRedact:
429+
return _t("Currently removing messages in %(count)s rooms", { count: keys.size });
430+
}
431+
})
432+
.join("\n");
409433

410434
let contextMenuButton: JSX.Element = <div className="mx_RoomListHeader_contextLessTitle">{ title }</div>;
411435
if (activeSpace || spaceKey === MetaSpace.Home) {
@@ -424,7 +448,9 @@ const RoomListHeader = ({ spacePanelDisabled, onVisibilityChange }: IProps) => {
424448

425449
return <div className="mx_RoomListHeader">
426450
{ contextMenuButton }
427-
{ pendingRoomJoinSpinner }
451+
{ pendingActionSummary ?
452+
<TooltipTarget label={pendingActionSummary}><InlineSpinner /></TooltipTarget> :
453+
null }
428454
{ canShowPlusMenu && <ContextMenuTooltipButton
429455
inputRef={plusMenuHandle}
430456
onClick={openPlusMenu}

0 commit comments

Comments
 (0)