diff --git a/res/css/views/messages/_RedactedBody.scss b/res/css/views/messages/_RedactedBody.scss index c0001db5d3..c0e5be2c89 100644 --- a/res/css/views/messages/_RedactedBody.scss +++ b/res/css/views/messages/_RedactedBody.scss @@ -15,6 +15,7 @@ limitations under the License. .mx_RedactedBody { white-space: pre-wrap; color: $muted-fg-color; + vertical-align: middle; padding-left: 16px; position: relative; diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 6fbfdb504b..30c139d440 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -29,6 +29,7 @@ import SettingsStore from '../../settings/SettingsStore'; import {_t} from "../../languageHandler"; import {haveTileForEvent} from "../views/rooms/EventTile"; import {textForEvent} from "../../TextForEvent"; +import RedactionEventListSummary from "../views/elements/RedactionEventListSummary"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = ['m.sticker', 'm.room.message']; @@ -1062,5 +1063,102 @@ class MemberGrouper { } } +// Wrap consecutive redactions by the same user in a ListSummary, ignore if redacted +class RedactionGrouper { + static canStartGroup = function(panel, ev) { + return panel._shouldShowEvent(ev) && ev.isRedacted(); + } + + constructor(panel, ev, prevEvent, lastShownEvent) { + this.panel = panel; + this.readMarker = panel._readMarkerForEvent( + ev.getId(), + ev === lastShownEvent, + ); + this.events = [ev]; + this.prevEvent = prevEvent; + this.lastShownEvent = lastShownEvent; + } + + shouldGroup(ev) { + if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) return false; + if (ev.getType() === "m.room.redaction") return true; // for show-hidden-events users + return ev.isRedacted() && ev.sender === this.events[0].sender && + ev.getUnsigned().redacted_because.sender === this.events[0].getUnsigned().redacted_because.sender; + } + + add(ev) { + if (ev.getType() === "m.room.redaction") return; // for show-hidden-events users + this.readMarker = this.readMarker || this.panel._readMarkerForEvent( + ev.getId(), + ev === this.lastShownEvent, + ); + this.events.push(ev); + } + + getTiles() { + // If we don't have any events to group, don't even try to group them. The logic + // below assumes that we have a group of events to deal with, but we might not if + // the events we were supposed to group were redacted. + if (!this.events || !this.events.length) return []; + + const DateSeparator = sdk.getComponent('messages.DateSeparator'); + + const panel = this.panel; + const lastShownEvent = this.lastShownEvent; + const ret = []; + + if (panel._wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { + const ts = this.events[0].getTs(); + ret.push( +
  • , + ); + } + + // Ensure that the key of the MemberEventListSummary does not change with new + // member events. This will prevent it from being re-created unnecessarily, and + // instead will allow new props to be provided. In turn, the shouldComponentUpdate + // method on ELS can be used to prevent unnecessary renderings. + const key = "redactioneventlistsummary-" + (this.prevEvent ? this.events[0].getId() : "initial"); + + let highlightInMels = false; + let eventTiles = this.events.map((e) => { + if (e.getId() === panel.props.highlightedEventId) { + highlightInMels = true; + } + // In order to prevent DateSeparators from appearing in the expanded form + // of MemberEventListSummary, render each member event as if the previous + // one was itself. This way, the timestamp of the previous event === the + // timestamp of the current event, and no DateSeparator is inserted. + return panel._getTilesForEvent(e, e, e === lastShownEvent); + }).reduce((a, b) => a.concat(b), []); + + if (eventTiles.length === 0) { + eventTiles = null; + } + + ret.push( + + { eventTiles } + , + ); + + if (this.readMarker) { + ret.push(this.readMarker); + } + + return ret; + } + + getNewPrevEvent() { + return this.events[0]; + } +} + // all the grouper classes that we use -const groupers = [CreationGrouper, MemberGrouper]; +const groupers = [CreationGrouper, MemberGrouper, RedactionGrouper]; diff --git a/src/components/views/elements/RedactionEventListSummary.tsx b/src/components/views/elements/RedactionEventListSummary.tsx new file mode 100644 index 0000000000..55538ca236 --- /dev/null +++ b/src/components/views/elements/RedactionEventListSummary.tsx @@ -0,0 +1,81 @@ +/* +Copyright 2020 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 from "react"; +import {MatrixClient} from "matrix-js-sdk/src/client"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; + +import { _t } from "../../../languageHandler"; +import * as sdk from "../../../index"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; + +interface IProps { + // An array of member events to summarise + events: MatrixEvent[]; + // An array of EventTiles to render when expanded + children: React.ReactChildren; + // The minimum number of events needed to trigger summarisation + threshold?: number; + // Called when the ELS expansion is toggled + onToggle: () => void; + // Whether or not to begin with state.expanded=true + startExpanded?: boolean; +} + +export default class RedactionEventListSummary extends React.Component { + static displayName = "RedactionEventListSummary"; + + static defaultProps = { + threshold: 2, + }; + + static contextType = MatrixClientContext; + + shouldComponentUpdate(nextProps) { + // Update if + // - The number of summarised events has changed + // - or if the summary is about to toggle to become collapsed + // - or if there are fewEvents, meaning the child eventTiles are shown as-is + return ( + nextProps.events.length !== this.props.events.length || + nextProps.events.length < this.props.threshold + ); + } + + render() { + const count = this.props.events.length; + const redactionSender = this.props.events[0].getUnsigned().redacted_because.sender; + + let avatarMember = this.props.events[0].sender; + let summaryText = _t("%(count)s messages deleted", { count }); + if (redactionSender !== this.context.getUserId()) { + const room = (this.context as MatrixClient).getRoom(redactionSender || this.props.events[0].getSender()); + avatarMember = room && room.getMember(redactionSender); + const name = avatarMember ? avatarMember.name : redactionSender; + summaryText = _t("%(count)s messages deleted by %(name)s", { count, name }); + } + + const EventListSummary = sdk.getComponent("views.elements.EventListSummary"); + return ; + } +} diff --git a/src/components/views/messages/RedactedBody.tsx b/src/components/views/messages/RedactedBody.tsx index f219e3bd91..654f1622b1 100644 --- a/src/components/views/messages/RedactedBody.tsx +++ b/src/components/views/messages/RedactedBody.tsx @@ -32,7 +32,7 @@ const RedactedBody = React.forwardRef(({mxEvent}, ref) => { if (redactedBecauseUserId !== cli.getUserId()) { const room = cli.getRoom(mxEvent.getRoomId()); const sender = room && room.getMember(redactedBecauseUserId); - text = _t("Message deleted by %(user)s", { user: sender.name || redactedBecauseUserId }); + text = _t("Message deleted by %(name)s", { name: sender ? sender.name : redactedBecauseUserId }); } return ( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 356e80afcf..53818d4747 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1329,7 +1329,7 @@ " reacted with %(content)s": " reacted with %(content)s", "reacted with %(shortName)s": "reacted with %(shortName)s", "Message deleted": "Message deleted", - "Message deleted by %(user)s": "Message deleted by %(user)s", + "Message deleted by %(name)s": "Message deleted by %(name)s", "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s", "%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.", "%(senderDisplayName)s changed the room avatar to ": "%(senderDisplayName)s changed the room avatar to ", @@ -1487,6 +1487,8 @@ "%(oneUser)smade no changes %(count)s times|one": "%(oneUser)smade no changes", "Power level": "Power level", "Custom level": "Custom level", + "%(count)s messages deleted|other": "%(count)s messages deleted", + "%(count)s messages deleted by %(name)s|other": "%(count)s messages deleted by %(name)s", "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.", "In reply to ": "In reply to ", "Room alias": "Room alias",