diff --git a/res/css/_components.scss b/res/css/_components.scss index 4865a8be15..2d2e3f0de7 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -78,6 +78,7 @@ @import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_AnalyticsLearnMoreDialog.scss"; @import "./views/dialogs/_BugReportDialog.scss"; +@import "./views/dialogs/_BulkRedactDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @import "./views/dialogs/_CommunityPrototypeInviteDialog.scss"; diff --git a/res/css/views/dialogs/_BulkRedactDialog.scss b/res/css/views/dialogs/_BulkRedactDialog.scss new file mode 100644 index 0000000000..c6b2adff87 --- /dev/null +++ b/res/css/views/dialogs/_BulkRedactDialog.scss @@ -0,0 +1,26 @@ +/* +Copyright 2021 Robin Townsend + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_BulkRedactDialog { + .mx_Checkbox, .mx_BulkRedactDialog_checkboxMicrocopy { + line-height: $font-20px; + } + + .mx_BulkRedactDialog_checkboxMicrocopy { + margin-left: 26px; + color: $secondary-content; + } +} diff --git a/src/components/views/dialogs/BulkRedactDialog.tsx b/src/components/views/dialogs/BulkRedactDialog.tsx new file mode 100644 index 0000000000..86c0f4033f --- /dev/null +++ b/src/components/views/dialogs/BulkRedactDialog.tsx @@ -0,0 +1,136 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useState } from 'react'; +import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixClient } from 'matrix-js-sdk/src/client'; +import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; +import { Room } from 'matrix-js-sdk/src/models/room'; +import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { _t } from '../../../languageHandler'; +import dis from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { IDialogProps } from "../dialogs/IDialogProps"; +import BaseDialog from "../dialogs/BaseDialog"; +import InfoDialog from "../dialogs/InfoDialog"; +import DialogButtons from "../elements/DialogButtons"; +import StyledCheckbox from "../elements/StyledCheckbox"; + +interface IBulkRedactDialogProps extends IDialogProps { + matrixClient: MatrixClient; + room: Room; + member: RoomMember; +} + +const BulkRedactDialog: React.FC = props => { + const { matrixClient: cli, room, member, onFinished } = props; + const [keepStateEvents, setKeepStateEvents] = useState(true); + + let timeline = room.getLiveTimeline(); + let eventsToRedact = []; + while (timeline) { + eventsToRedact = [...eventsToRedact, ...timeline.getEvents().filter(event => + event.getSender() === member.userId && + !event.isRedacted() && !event.isRedaction() && + event.getType() !== EventType.RoomCreate && + // Don't redact ACLs because that'll obliterate the room + // See https://github.com/matrix-org/synapse/issues/4042 for details. + event.getType() !== EventType.RoomServerAcl && + // Redacting encryption events is equally bad + event.getType() !== EventType.RoomEncryption, + )]; + timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS); + } + + if (eventsToRedact.length === 0) { + return +

{ _t("Try scrolling up in the timeline to see if there are any earlier ones.") }

+ + } + />; + } else { + eventsToRedact = eventsToRedact.filter(event => !(keepStateEvents && event.isState())); + const count = eventsToRedact.length; + const user = member.name; + + const redact = async () => { + logger.info(`Started redacting recent ${count} messages for ${member.userId} in ${room.roomId}`); + dis.dispatch({ + action: Action.BulkRedactStart, + room_id: room.roomId, + }); + + // Submitting a large number of redactions freezes the UI, + // so first yield to allow to rerender after closing the dialog. + await Promise.resolve(); + await Promise.all(eventsToRedact.reverse().map(async event => { + try { + await cli.redactEvent(room.roomId, event.getId()); + } catch (err) { + // log and swallow errors + logger.error("Could not redact", event.getId()); + logger.error(err); + } + })); + + logger.info(`Finished redacting recent ${count} messages for ${member.userId} in ${room.roomId}`); + dis.dispatch({ + action: Action.BulkRedactEnd, + room_id: room.roomId, + }); + }; + + return +
+

{ _t("You are about to remove %(count)s messages by %(user)s. " + + "This will remove them permanently for everyone in the conversation. " + + "Do you wish to continue?", { count, user }) }

+

{ _t("For a large amount of messages, this might take some time. " + + "Please don't refresh your client in the meantime.") }

+ setKeepStateEvents(e.target.checked)} + > + { _t("Preserve system messages") } + +
+ { _t("Uncheck if you also want to remove system messages on this user " + + "(e.g. membership change, profile change…)") } +
+
+ { setImmediate(redact); onFinished(true); }} + onCancel={() => onFinished(false)} + /> +
; + } +}; + +export default BulkRedactDialog; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 394de5bac3..c4e4e9b282 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -23,7 +23,6 @@ import { ClientEvent, MatrixClient } from 'matrix-js-sdk/src/client'; import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; import { User } from 'matrix-js-sdk/src/models/user'; import { Room } from 'matrix-js-sdk/src/models/room'; -import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import { EventType } from "matrix-js-sdk/src/@types/event"; @@ -60,11 +59,11 @@ import Spinner from "../elements/Spinner"; import PowerSelector from "../elements/PowerSelector"; import MemberAvatar from "../avatars/MemberAvatar"; import PresenceLabel from "../rooms/PresenceLabel"; +import BulkRedactDialog from "../dialogs/BulkRedactDialog"; import ShareDialog from "../dialogs/ShareDialog"; import ErrorDialog from "../dialogs/ErrorDialog"; import QuestionDialog from "../dialogs/QuestionDialog"; import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog"; -import InfoDialog from "../dialogs/InfoDialog"; import RoomAvatar from "../avatars/RoomAvatar"; import RoomName from "../elements/RoomName"; import { mediaFromMxc } from "../../../customisations/Media"; @@ -629,75 +628,14 @@ const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit = ({ member }) => { const cli = useContext(MatrixClientContext); - const onRedactAllMessages = async () => { - const { roomId, userId } = member; - const room = cli.getRoom(roomId); - if (!room) { - return; - } - let timeline = room.getLiveTimeline(); - let eventsToRedact = []; - while (timeline) { - eventsToRedact = timeline.getEvents().reduce((events, event) => { - if (event.getSender() === userId && !event.isRedacted() && !event.isRedaction() && - event.getType() !== EventType.RoomCreate && - // Don't redact ACLs because that'll obliterate the room - // See https://github.com/matrix-org/synapse/issues/4042 for details. - event.getType() !== EventType.RoomServerAcl - ) { - return events.concat(event); - } else { - return events; - } - }, eventsToRedact); - timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS); - } + const onRedactAllMessages = () => { + const room = cli.getRoom(member.roomId); + if (!room) return; - const count = eventsToRedact.length; - const user = member.name; - - if (count === 0) { - Modal.createTrackedDialog('No user messages found to remove', '', InfoDialog, { - title: _t("No recent messages by %(user)s found", { user }), - description: -
-

{ _t("Try scrolling up in the timeline to see if there are any earlier ones.") }

-
, - }); - } else { - const { finished } = Modal.createTrackedDialog('Remove recent messages by user', '', QuestionDialog, { - title: _t("Remove recent messages by %(user)s", { user }), - description: -
-

{ _t("You are about to remove %(count)s messages by %(user)s. " + - "This cannot be undone. Do you wish to continue?", { count, user }) }

-

{ _t("For a large amount of messages, this might take some time. " + - "Please don't refresh your client in the meantime.") }

-
, - button: _t("Remove %(count)s messages", { count }), - }); - - const [confirmed] = await finished; - if (!confirmed) { - return; - } - - // Submitting a large number of redactions freezes the UI, - // so first yield to allow to rerender after closing the dialog. - await Promise.resolve(); - - logger.info(`Started redacting recent ${count} messages for ${user} in ${roomId}`); - await Promise.all(eventsToRedact.map(async event => { - try { - await cli.redactEvent(roomId, event.getId()); - } catch (err) { - // log and swallow errors - logger.error("Could not redact", event.getId()); - logger.error(err); - } - })); - logger.info(`Finished redacting recent ${count} messages for ${user} in ${roomId}`); - } + Modal.createTrackedDialog("Bulk Redact Dialog", "", BulkRedactDialog, { + matrixClient: cli, + room, member, + }); }; return diff --git a/src/components/views/rooms/RoomListHeader.tsx b/src/components/views/rooms/RoomListHeader.tsx index fef7c6807f..958c2a533a 100644 --- a/src/components/views/rooms/RoomListHeader.tsx +++ b/src/components/views/rooms/RoomListHeader.tsx @@ -137,29 +137,50 @@ const PrototypeCommunityContextMenu = (props: ComponentProps; }; -const useJoiningRooms = (): Set => { +// Long-running actions that should trigger a spinner +enum PendingActionType { + JoinRoom, + BulkRedact, +} + +const usePendingActions = (): Map> => { const cli = useContext(MatrixClientContext); - const [joiningRooms, setJoiningRooms] = useState(new Set()); + const [actions, setActions] = useState(new Map>()); + + const addAction = (type: PendingActionType, key: string) => { + const keys = new Set(actions.get(type)); + keys.add(key); + setActions(new Map(actions).set(type, keys)); + }; + const removeAction = (type: PendingActionType, key: string) => { + const keys = new Set(actions.get(type)); + if (keys.delete(key)) { + setActions(new Map(actions).set(type, keys)); + } + }; + useDispatcher(defaultDispatcher, payload => { switch (payload.action) { case Action.JoinRoom: - setJoiningRooms(new Set(joiningRooms.add(payload.roomId))); + addAction(PendingActionType.JoinRoom, payload.roomId); break; case Action.JoinRoomReady: case Action.JoinRoomError: - if (joiningRooms.delete(payload.roomId)) { - setJoiningRooms(new Set(joiningRooms)); - } + removeAction(PendingActionType.JoinRoom, payload.roomId); + break; + case Action.BulkRedactStart: + addAction(PendingActionType.BulkRedact, payload.roomId); + break; + case Action.BulkRedactEnd: + removeAction(PendingActionType.BulkRedact, payload.roomId); break; } }); - useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) => { - if (joiningRooms.delete(room.roomId)) { - setJoiningRooms(new Set(joiningRooms)); - } - }); + useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) => + removeAction(PendingActionType.JoinRoom, room.roomId), + ); - return joiningRooms; + return actions; }; interface IProps { @@ -179,7 +200,7 @@ const RoomListHeader = ({ spacePanelDisabled, onVisibilityChange }: IProps) => { const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => { return SpaceStore.instance.allRoomsInHome; }); - const joiningRooms = useJoiningRooms(); + const pendingActions = usePendingActions(); const filterCondition = RoomListStore.instance.getFirstNameFilterCondition(); const count = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => { @@ -398,14 +419,17 @@ const RoomListHeader = ({ spacePanelDisabled, onVisibilityChange }: IProps) => { title = getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome); } - let pendingRoomJoinSpinner: JSX.Element; - if (joiningRooms.size) { - pendingRoomJoinSpinner = - - ; - } + const pendingActionSummary = [...pendingActions.entries()] + .filter(([type, keys]) => keys.size > 0) + .map(([type, keys]) => { + switch (type) { + case PendingActionType.JoinRoom: + return _t("Currently joining %(count)s rooms", { count: keys.size }); + case PendingActionType.BulkRedact: + return _t("Currently removing messages in %(count)s rooms", { count: keys.size }); + } + }) + .join("\n"); let contextMenuButton: JSX.Element =
{ title }
; if (activeSpace || spaceKey === MetaSpace.Home) { @@ -424,7 +448,9 @@ const RoomListHeader = ({ spacePanelDisabled, onVisibilityChange }: IProps) => { return
{ contextMenuButton } - { pendingRoomJoinSpinner } + { pendingActionSummary ? + : + null } { canShowPlusMenu &&