diff --git a/res/css/_components.scss b/res/css/_components.scss index 323dc2841b..8f27c1c4ca 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -141,7 +141,7 @@ @import "./views/elements/_Dropdown.scss"; @import "./views/elements/_EditableItemList.scss"; @import "./views/elements/_ErrorBoundary.scss"; -@import "./views/elements/_EventListSummary.scss"; +@import "./views/elements/_GenericEventListSummary.scss"; @import "./views/elements/_EventTilePreview.scss"; @import "./views/elements/_ExternalLink.scss"; @import "./views/elements/_FacePile.scss"; diff --git a/res/css/views/elements/_EventListSummary.scss b/res/css/views/elements/_GenericEventListSummary.scss similarity index 75% rename from res/css/views/elements/_EventListSummary.scss rename to res/css/views/elements/_GenericEventListSummary.scss index bb82ff0dd7..cd0a8c51dd 100644 --- a/res/css/views/elements/_EventListSummary.scss +++ b/res/css/views/elements/_GenericEventListSummary.scss @@ -14,28 +14,28 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_EventListSummary { +.mx_GenericEventListSummary { position: relative; } -.mx_TextualEvent.mx_EventListSummary_summary { +.mx_TextualEvent.mx_GenericEventListSummary_summary { font-size: $font-14px; display: inline-flex; } -.mx_EventListSummary_avatars { +.mx_GenericEventListSummary_avatars { display: inline-block; margin-right: 8px; padding-top: 8px; line-height: $font-12px; } -.mx_EventListSummary_avatars .mx_BaseAvatar { +.mx_GenericEventListSummary_avatars .mx_BaseAvatar { margin-right: -4px; cursor: pointer; } -.mx_EventListSummary_toggle { +.mx_GenericEventListSummary_toggle { color: $accent; cursor: pointer; float: right; @@ -43,29 +43,29 @@ limitations under the License. margin-top: 8px; } -.mx_EventListSummary_line { +.mx_GenericEventListSummary_line { border-bottom: 1px solid $primary-hairline-color; margin-left: 63px; line-height: $font-30px; } .mx_MatrixChat_useCompactLayout { - .mx_EventListSummary { + .mx_GenericEventListSummary { font-size: $font-13px; .mx_EventTile_line { line-height: $font-20px; } } - .mx_EventListSummary_line { + .mx_GenericEventListSummary_line { line-height: $font-22px; } - .mx_EventListSummary_toggle { + .mx_GenericEventListSummary_toggle { margin-top: 3px; } - .mx_TextualEvent.mx_EventListSummary_summary { + .mx_TextualEvent.mx_GenericEventListSummary_summary { font-size: $font-13px; } } diff --git a/res/css/views/right_panel/_ThreadPanel.scss b/res/css/views/right_panel/_ThreadPanel.scss index 3d8cf15d80..31ddbcafda 100644 --- a/res/css/views/right_panel/_ThreadPanel.scss +++ b/res/css/views/right_panel/_ThreadPanel.scss @@ -118,7 +118,7 @@ limitations under the License. padding-right: 0; } - .mx_EventTile, .mx_EventListSummary { + .mx_EventTile, .mx_GenericEventListSummary { // Account for scrollbar when hovering padding-top: 0; diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index 57e95238fe..a9d2cd8b09 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -23,7 +23,7 @@ limitations under the License. } .mx_EventTile[data-layout=bubble], -.mx_EventListSummary[data-layout=bubble] { +.mx_GenericEventListSummary[data-layout=bubble] { --avatarSize: 32px; --gutterSize: 11px; --cornerRadius: 12px; @@ -477,7 +477,7 @@ limitations under the License. .mx_EventTile.mx_EventTile_bubbleContainer[data-layout=bubble], .mx_EventTile.mx_EventTile_leftAlignedBubble[data-layout=bubble], .mx_EventTile.mx_EventTile_info[data-layout=bubble], -.mx_EventListSummary[data-layout=bubble][data-expanded=false] { +.mx_GenericEventListSummary[data-layout=bubble][data-expanded=false] { --backgroundColor: transparent; --gutterSize: 0; @@ -518,11 +518,11 @@ limitations under the License. } } -.mx_EventListSummary[data-layout=bubble] { +.mx_GenericEventListSummary[data-layout=bubble] { --maxWidth: 70%; margin-left: calc(var(--avatarSize) + var(--gutterSize)); - .mx_EventListSummary_toggle { + .mx_GenericEventListSummary_toggle { margin: 0 55px 0 5px; float: none; @@ -534,11 +534,11 @@ limitations under the License. } } - .mx_EventListSummary_line { + .mx_GenericEventListSummary_line { display: none; } - .mx_EventListSummary_avatars { + .mx_GenericEventListSummary_avatars { padding-top: 0; } @@ -548,14 +548,14 @@ limitations under the License. } } -.mx_EventListSummary[data-expanded=false][data-layout=bubble] { +.mx_GenericEventListSummary[data-expanded=false][data-layout=bubble] { // Align with left edge of bubble tiles padding: 0 49px; } // ideally we'd use display=contents here for the layout to all work regardless of the *ELS but // that breaks ScrollPanel's reliance upon offsetTop so we have to have a bit more finesse. -.mx_EventListSummary[data-expanded=true][data-layout=bubble] { +.mx_GenericEventListSummary[data-expanded=true][data-layout=bubble] { display: flex; flex-direction: column; margin: 0; @@ -579,12 +579,12 @@ limitations under the License. } // increase margin between ELS and the next Event to not have our user avatar overlap the expand/collapse button -.mx_EventListSummary[data-layout=bubble][data-expanded=false] + .mx_EventTile[data-layout=bubble][data-self=true] { +.mx_GenericEventListSummary[data-layout=bubble][data-expanded=false] + .mx_EventTile[data-layout=bubble][data-self=true] { margin-top: 20px; } /* events that do not require bubble layout */ -.mx_EventListSummary[data-layout=bubble], +.mx_GenericEventListSummary[data-layout=bubble], .mx_EventTile.mx_EventTile_bad[data-layout=bubble] { .mx_EventTile_line { background: transparent; diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 1cdc83ec14..deb71fa678 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -281,11 +281,11 @@ $left-gutter: 64px; } .mx_EventTile:not([data-layout=bubble]).mx_EventTile_info .mx_EventTile_line, -.mx_EventListSummary:not([data-layout=bubble]) > :not(.mx_EventTile) .mx_EventTile_avatar ~ .mx_EventTile_line { +.mx_GenericEventListSummary:not([data-layout=bubble]) > :not(.mx_EventTile) .mx_EventTile_avatar ~ .mx_EventTile_line { padding-left: calc($left-gutter + 18px); } -.mx_EventListSummary:not([data-layout=bubble]) .mx_EventTile_line { +.mx_GenericEventListSummary:not([data-layout=bubble]) .mx_EventTile_line { padding-left: $left-gutter; } diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index 7b7bc953a4..14b1c4ad60 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -139,12 +139,12 @@ $irc-line-height: $font-18px; margin: 0; } - .mx_EventListSummary { + .mx_GenericEventListSummary { > .mx_EventTile_line { padding-left: calc(var(--name-width) + $icon-width + $timestamp-width + 3 * $right-padding); // 15 px of padding } - .mx_EventListSummary_avatars { + .mx_GenericEventListSummary_avatars { padding: 0; margin: 0 9px 0 0; } diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 1a4cedec5f..0d4af1d06c 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -20,7 +20,6 @@ import { Room } from 'matrix-js-sdk/src/models/room'; import { EventType } from 'matrix-js-sdk/src/@types/event'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { Relations } from "matrix-js-sdk/src/models/relations"; -import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; import { logger } from 'matrix-js-sdk/src/logger'; import shouldHideEvent from '../../shouldHideEvent'; @@ -41,8 +40,8 @@ import defaultDispatcher from '../../dispatcher/dispatcher'; import CallEventGrouper from "./CallEventGrouper"; import WhoIsTypingTile from '../views/rooms/WhoIsTypingTile'; import ScrollPanel, { IScrollState } from "./ScrollPanel"; +import GenericEventListSummary from '../views/elements/GenericEventListSummary'; import EventListSummary from '../views/elements/EventListSummary'; -import MemberEventListSummary from '../views/elements/MemberEventListSummary'; import DateSeparator from '../views/messages/DateSeparator'; import ErrorBoundary from '../views/elements/ErrorBoundary'; import ResizeNotifier from "../../utils/ResizeNotifier"; @@ -651,7 +650,7 @@ export default class MessagePanel extends React.Component { if (grouper) { if (grouper.shouldGroup(mxEv)) { - grouper.add(mxEv, this.showHiddenEvents); + grouper.add(mxEv); continue; } else { // not part of group, so get the group tiles, close the @@ -1047,7 +1046,7 @@ abstract class BaseGrouper { } public abstract shouldGroup(ev: MatrixEvent): boolean; - public abstract add(ev: MatrixEvent, showHiddenEvents?: boolean): void; + public abstract add(ev: MatrixEvent): void; public abstract getTiles(): ReactNode[]; public abstract getNewPrevEvent(): MatrixEvent; } @@ -1064,7 +1063,7 @@ abstract class BaseGrouper { * when determining things such as whether a date separator is necessary */ -// Wrap initial room creation events into an EventListSummary +// Wrap initial room creation events into a GenericEventListSummary // Grouping only events sent by the same user that sent the `m.room.create` and only until // the first non-state event or membership event which is not regarding the sender of the `m.room.create` event class CreationGrouper extends BaseGrouper { @@ -1140,7 +1139,7 @@ class CreationGrouper extends BaseGrouper { const eventTiles = this.events.map((e) => { // In order to prevent DateSeparators from appearing in the expanded form - // of EventListSummary, render each member event as if the previous + // of GenericEventListSummary, 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, isGrouped); @@ -1160,7 +1159,7 @@ class CreationGrouper extends BaseGrouper { ret.push(); ret.push( - { eventTiles } - , + , ); if (this.readMarker) { @@ -1184,107 +1183,24 @@ class CreationGrouper extends BaseGrouper { } } -class RedactionGrouper extends BaseGrouper { +// Wrap consecutive grouped events in a ListSummary +class MainGrouper extends BaseGrouper { static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { - return panel.shouldShowEvent(ev) && ev.isRedacted(); - }; + if (!panel.shouldShowEvent(ev)) return false; - constructor( - panel: MessagePanel, - ev: MatrixEvent, - prevEvent: MatrixEvent, - lastShownEvent: MatrixEvent, - layout: Layout, - nextEvent: MatrixEvent, - nextEventTile: MatrixEvent, - ) { - super(panel, ev, prevEvent, lastShownEvent, layout, nextEvent, nextEventTile); - this.events = [ev]; - } - - public shouldGroup(ev: MatrixEvent): boolean { - // absorb hidden events so that they do not break up streams of messages & redaction events being grouped - if (!this.panel.shouldShowEvent(ev)) { + if (groupedEvents.includes(ev.getType() as EventType)) { return true; } - if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) { - return false; - } - return ev.isRedacted(); - } - public add(ev: MatrixEvent): void { - this.readMarker = this.readMarker || this.panel.readMarkerForEvent( - ev.getId(), - ev === this.lastShownEvent, - ); - if (!this.panel.shouldShowEvent(ev)) { - return; - } - this.events.push(ev); - } - - public getTiles(): ReactNode[] { - if (!this.events || !this.events.length) return []; - - const isGrouped = true; - const panel = this.panel; - const ret = []; - const lastShownEvent = this.lastShownEvent; - - if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { - const ts = this.events[0].getTs(); - ret.push( -
  • , - ); + if (ev.isRedacted()) { + return true; } - const key = "redactioneventlistsummary-" + ( - this.prevEvent ? this.events[0].getId() : "initial" - ); - - const senders = new Set(); - let eventTiles = this.events.map((e, i) => { - senders.add(e.sender); - const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1]; - return panel.getTilesForEvent( - prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile); - }).reduce((a, b) => a.concat(b), []); - - if (eventTiles.length === 0) { - eventTiles = null; + if (panel.showHiddenEvents && !panel.shouldShowEvent(ev, true)) { + return true; } - ret.push( - - { eventTiles } - , - ); - - if (this.readMarker) { - ret.push(this.readMarker); - } - - return ret; - } - - public getNewPrevEvent(): MatrixEvent { - return this.events[this.events.length - 1]; - } -} - -// Wrap consecutive member events in a ListSummary, ignore if redacted -class MemberGrouper extends BaseGrouper { - static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { - return panel.shouldShowEvent(ev) && groupedEvents.includes(ev.getType() as EventType); + return false; }; constructor( @@ -1293,27 +1209,43 @@ class MemberGrouper extends BaseGrouper { public readonly prevEvent: MatrixEvent, public readonly lastShownEvent: MatrixEvent, protected readonly layout: Layout, + nextEvent: MatrixEvent, + nextEventTile: MatrixEvent, ) { - super(panel, event, prevEvent, lastShownEvent, layout); + super(panel, event, prevEvent, lastShownEvent, layout, nextEvent, nextEventTile); this.events = [event]; } public shouldGroup(ev: MatrixEvent): boolean { + if (!this.panel.shouldShowEvent(ev)) { + // absorb hidden events so that they do not break up streams of messages & redaction events being grouped + return true; + } if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) { return false; } - return groupedEvents.includes(ev.getType() as EventType); + if (groupedEvents.includes(ev.getType() as EventType)) { + return true; + } + if (ev.isRedacted()) { + return true; + } + if (this.panel.showHiddenEvents && !this.panel.shouldShowEvent(ev, true)) { + return true; + } + return false; } - public add(ev: MatrixEvent, showHiddenEvents?: boolean): void { + public add(ev: MatrixEvent): void { if (ev.getType() === EventType.RoomMember) { // We can ignore any events that don't actually have a message to display - if (!hasText(ev, showHiddenEvents)) return; + if (!hasText(ev, this.panel.showHiddenEvents)) return; + } + this.readMarker = this.readMarker || this.panel.readMarkerForEvent(ev.getId(), ev === this.lastShownEvent); + if (!this.panel.showHiddenEvents && !this.panel.shouldShowEvent(ev)) { + // absorb hidden events to not split the summary + return; } - this.readMarker = this.readMarker || this.panel.readMarkerForEvent( - ev.getId(), - ev === this.lastShownEvent, - ); this.events.push(ev); } @@ -1321,7 +1253,7 @@ class MemberGrouper extends BaseGrouper { // 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 []; + if (!this.events?.length) return []; const isGrouped = true; const panel = this.panel; @@ -1335,28 +1267,26 @@ class MemberGrouper extends BaseGrouper { ); } - // Ensure that the key of the MemberEventListSummary does not change with new - // member events. This will prevent it from being re-created unnecessarily, and + // Ensure that the key of the EventListSummary does not change with new 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 MELS can be used to prevent unnecessary renderings. + // method on ELS can be used to prevent unnecessary renderings. // - // Whilst back-paginating with a MELS at the top of the panel, prevEvent will be null, - // so use the key "membereventlistsummary-initial". Otherwise, use the ID of the first + // Whilst back-paginating with an ELS at the top of the panel, prevEvent will be null, + // so use the key "eventlistsummary-initial". Otherwise, use the ID of the first // membership event, which will not change during forward pagination. - const key = "membereventlistsummary-" + ( - this.prevEvent ? this.events[0].getId() : "initial" - ); + const key = "eventlistsummary-" + (this.prevEvent ? this.events[0].getId() : "initial"); - let highlightInMels; + let highlightInSummary = false; let eventTiles = this.events.map((e) => { if (e.getId() === panel.props.highlightedEventId) { - highlightInMels = true; + highlightInSummary = true; } // In order to prevent DateSeparators from appearing in the expanded form - // of MemberEventListSummary, render each member event as if the previous + // of EventListSummary, 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, isGrouped); + return panel.getTilesForEvent(e, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { @@ -1370,15 +1300,15 @@ class MemberGrouper extends BaseGrouper { } ret.push( - { eventTiles } - , + , ); if (this.readMarker) { @@ -1393,91 +1323,5 @@ class MemberGrouper extends BaseGrouper { } } -// Wrap consecutive hidden events in a ListSummary, ignore if redacted -class HiddenEventGrouper extends BaseGrouper { - static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { - return !panel.shouldShowEvent(ev, true) && panel.showHiddenEvents; - }; - - constructor( - public readonly panel: MessagePanel, - public readonly event: MatrixEvent, - public readonly prevEvent: MatrixEvent, - public readonly lastShownEvent: MatrixEvent, - protected readonly layout: Layout, - ) { - super(panel, event, prevEvent, lastShownEvent, layout); - this.events = [event]; - } - - public shouldGroup(ev: MatrixEvent): boolean { - if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) { - return false; - } - return !this.panel.shouldShowEvent(ev, true); - } - - public add(ev: MatrixEvent, showHiddenEvents?: boolean): void { - this.readMarker = this.readMarker || this.panel.readMarkerForEvent(ev.getId(), ev === this.lastShownEvent); - this.events.push(ev); - } - - public getTiles(): ReactNode[] { - if (!this.events || !this.events.length) return []; - - const isGrouped = true; - const panel = this.panel; - const ret = []; - const lastShownEvent = this.lastShownEvent; - - if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { - const ts = this.events[0].getTs(); - ret.push( -
  • , - ); - } - - const key = "hiddeneventlistsummary-" + ( - this.prevEvent ? this.events[0].getId() : "initial" - ); - - const senders = new Set(); - let eventTiles = this.events.map((e, i) => { - senders.add(e.sender); - const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1]; - return panel.getTilesForEvent( - prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile); - }).reduce((a, b) => a.concat(b), []); - - if (eventTiles.length === 0) { - eventTiles = null; - } - - ret.push( - , - ); - - if (this.readMarker) { - ret.push(this.readMarker); - } - - return ret; - } - - public getNewPrevEvent(): MatrixEvent { - return this.events[this.events.length - 1]; - } -} - // all the grouper classes that we use -const groupers = [CreationGrouper, MemberGrouper, RedactionGrouper, HiddenEventGrouper]; +const groupers = [CreationGrouper, MainGrouper]; diff --git a/src/components/views/elements/EventListSummary.tsx b/src/components/views/elements/EventListSummary.tsx index 10e18623fb..f1bf93cf8f 100644 --- a/src/components/views/elements/EventListSummary.tsx +++ b/src/components/views/elements/EventListSummary.tsx @@ -1,5 +1,7 @@ /* +Copyright 2016 OpenMarket Ltd Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,110 +16,510 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode, useEffect } from "react"; -import { uniqBy } from "lodash"; +import React, { ComponentProps } from 'react'; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import { logger } from "matrix-js-sdk/src/logger"; +import { EventType } from 'matrix-js-sdk/src/@types/event'; -import MemberAvatar from '../avatars/MemberAvatar'; import { _t } from '../../../languageHandler'; -import { useStateToggle } from "../../../hooks/useStateToggle"; -import AccessibleButton from "./AccessibleButton"; +import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; +import { isValid3pidInvite } from "../../../RoomInvite"; +import GenericEventListSummary from "./GenericEventListSummary"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { RightPanelPhases } from '../../../stores/right-panel/RightPanelStorePhases'; +import { jsxJoin } from '../../../utils/ReactUtils'; import { Layout } from '../../../settings/enums/Layout'; +import RightPanelStore from '../../../stores/right-panel/RightPanelStore'; +import AccessibleButton from './AccessibleButton'; -interface IProps { - // An array of member events to summarise - events: MatrixEvent[]; - // The minimum number of events needed to trigger summarisation - threshold?: number; - // Whether or not to begin with state.expanded=true - startExpanded?: boolean; - // The list of room members for which to show avatars next to the summary - summaryMembers?: RoomMember[]; - // The text to show as the summary of this event list - summaryText?: string | JSX.Element; - // An array of EventTiles to render when expanded - children: ReactNode[]; - // Called when the event list expansion is toggled - onToggle?(): void; - // The layout currently used - layout?: Layout; +const onPinnedMessagesClick = (): void => { + RightPanelStore.instance.setCard({ phase: RightPanelPhases.PinnedMessages }, false); +}; + +const TARGET_AS_DISPLAY_NAME_EVENTS = [EventType.RoomMember]; + +interface IProps extends Omit, "summaryText" | "summaryMembers"> { + // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left" + summaryLength?: number; + // The maximum number of avatars to display in the summary + avatarsMaxLength?: number; + // The currently selected layout + layout: Layout; } -const EventListSummary: React.FC = ({ - events, - children, - threshold = 3, - onToggle, - startExpanded, - summaryMembers = [], - summaryText, - layout, -}) => { - const [expanded, toggleExpanded] = useStateToggle(startExpanded); +interface IUserEvents { + // The original event + mxEvent: MatrixEvent; + // The display name of the user (if not, then user ID) + displayName: string; + // The original index of the event in this.props.events + index: number; +} - // Whenever expanded changes call onToggle - useEffect(() => { - if (onToggle) { - onToggle(); - } - }, [expanded]); // eslint-disable-line react-hooks/exhaustive-deps +enum TransitionType { + Joined = "joined", + Left = "left", + JoinedAndLeft = "joined_and_left", + LeftAndJoined = "left_and_joined", + InviteReject = "invite_reject", + InviteWithdrawal = "invite_withdrawal", + Invited = "invited", + Banned = "banned", + Unbanned = "unbanned", + Kicked = "kicked", + ChangedName = "changed_name", + ChangedAvatar = "changed_avatar", + NoChange = "no_change", + ServerAcl = "server_acl", + ChangedPins = "pinned_messages", + MessageRemoved = "message_removed", + HiddenEvent = "hidden_event", +} - const eventIds = events.map((e) => e.getId()).join(','); +const SEP = ","; - // If we are only given few events then just pass them through - if (events.length < threshold) { +@replaceableComponent("views.elements.EventListSummary") +export default class EventListSummary extends React.Component { + static defaultProps = { + summaryLength: 1, + threshold: 3, + avatarsMaxLength: 5, + layout: Layout.Group, + }; + + shouldComponentUpdate(nextProps: IProps): boolean { + // 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 ( -
  • - { children } -
  • + nextProps.events.length !== this.props.events.length || + nextProps.events.length < this.props.threshold || + nextProps.layout !== this.props.layout ); } - let body; - if (expanded) { - body = -
     
    - { children } -
    ; - } else { - const uniqueMembers = uniqBy(summaryMembers.filter(member => { - if (!member?.getMxcAvatarUrl) { - logger.error("EventListSummary given null summaryMember, termites may be afoot eating event senders", - summaryMembers); - return false; + /** + * Generate the text for users aggregated by their transition sequences (`eventAggregates`) where + * the sequences are ordered by `orderedTransitionSequences`. + * @param {object} eventAggregates a map of transition sequence to array of user display names + * or user IDs. + * @param {string[]} orderedTransitionSequences an array which is some ordering of + * `Object.keys(eventAggregates)`. + * @returns {string} the textual summary of the aggregated events that occurred. + */ + private generateSummary( + eventAggregates: Record, + orderedTransitionSequences: string[], + ): string | JSX.Element { + const summaries = orderedTransitionSequences.map((transitions) => { + const userNames = eventAggregates[transitions]; + const nameList = this.renderNameList(userNames); + + const splitTransitions = transitions.split(SEP) as TransitionType[]; + + // Some neighbouring transitions are common, so canonicalise some into "pair" + // transitions + const canonicalTransitions = EventListSummary.getCanonicalTransitions(splitTransitions); + // Transform into consecutive repetitions of the same transition (like 5 + // consecutive 'joined_and_left's) + const coalescedTransitions = EventListSummary.coalesceRepeatedTransitions(canonicalTransitions); + + const descs = coalescedTransitions.map((t) => { + return EventListSummary.getDescriptionForTransition( + t.transitionType, userNames.length, t.repeats, + ); + }); + + const desc = formatCommaSeparatedList(descs); + + return _t('%(nameList)s %(transitionList)s', { nameList, transitionList: desc }); + }); + + if (!summaries) { + return null; + } + + return jsxJoin(summaries, ", "); + } + + /** + * @param {string[]} users an array of user display names or user IDs. + * @returns {string} a comma-separated list that ends with "and [n] others" if there are + * more items in `users` than `this.props.summaryLength`, which is the number of names + * included before "and [n] others". + */ + private renderNameList(users: string[]) { + return formatCommaSeparatedList(users, this.props.summaryLength); + } + + /** + * Canonicalise an array of transitions such that some pairs of transitions become + * single transitions. For example an input ['joined','left'] would result in an output + * ['joined_and_left']. + * @param {string[]} transitions an array of transitions. + * @returns {string[]} an array of transitions. + */ + private static getCanonicalTransitions(transitions: TransitionType[]): TransitionType[] { + const modMap = { + [TransitionType.Joined]: { + after: TransitionType.Left, + newTransition: TransitionType.JoinedAndLeft, + }, + [TransitionType.Left]: { + after: TransitionType.Joined, + newTransition: TransitionType.LeftAndJoined, + }, + // $currentTransition : { + // 'after' : $nextTransition, + // 'newTransition' : 'new_transition_type', + // }, + }; + const res: TransitionType[] = []; + + for (let i = 0; i < transitions.length; i++) { + const t = transitions[i]; + const t2 = transitions[i + 1]; + + let transition = t; + + if (i < transitions.length - 1 && modMap[t] && modMap[t].after === t2) { + transition = modMap[t].newTransition; + i++; } - return true; - }), member => member.getMxcAvatarUrl()); - const avatars = uniqueMembers.map((m) => ); - body = ( -
    -
    - - { avatars } - - - { summaryText } - -
    -
    - ); + + res.push(transition); + } + return res; } - return ( -
  • - - { expanded ? _t('collapse') : _t('expand') } - - { body } -
  • - ); -}; + /** + * Transform an array of transitions into an array of transitions and how many times + * they are repeated consecutively. + * + * An array of 123 "joined_and_left" transitions, would result in: + * ``` + * [{ + * transitionType: "joined_and_left" + * repeats: 123 + * }] + * ``` + * @param {string[]} transitions the array of transitions to transform. + * @returns {object[]} an array of coalesced transitions. + */ + private static coalesceRepeatedTransitions(transitions: TransitionType[]) { + const res: { + transitionType: TransitionType; + repeats: number; + }[] = []; -EventListSummary.defaultProps = { - startExpanded: false, - layout: Layout.Group, -}; + for (let i = 0; i < transitions.length; i++) { + if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) { + res[res.length - 1].repeats += 1; + } else { + res.push({ + transitionType: transitions[i], + repeats: 1, + }); + } + } + return res; + } -export default EventListSummary; + /** + * For a certain transition, t, describe what happened to the users that + * underwent the transition. + * @param {string} t the transition type. + * @param {number} userCount number of usernames + * @param {number} repeats the number of times the transition was repeated in a row. + * @returns {string} the written Human Readable equivalent of the transition. + */ + private static getDescriptionForTransition( + t: TransitionType, + userCount: number, + count: number, + ): string | JSX.Element { + // The empty interpolations 'severalUsers' and 'oneUser' + // are there only to show translators to non-English languages + // that the verb is conjugated to plural or singular Subject. + let res = null; + switch (t) { + case TransitionType.Joined: + res = (userCount > 1) + ? _t("%(severalUsers)sjoined %(count)s times", { severalUsers: "", count }) + : _t("%(oneUser)sjoined %(count)s times", { oneUser: "", count }); + break; + case TransitionType.Left: + res = (userCount > 1) + ? _t("%(severalUsers)sleft %(count)s times", { severalUsers: "", count }) + : _t("%(oneUser)sleft %(count)s times", { oneUser: "", count }); + break; + case TransitionType.JoinedAndLeft: + res = (userCount > 1) + ? _t("%(severalUsers)sjoined and left %(count)s times", { severalUsers: "", count }) + : _t("%(oneUser)sjoined and left %(count)s times", { oneUser: "", count }); + break; + case TransitionType.LeftAndJoined: + res = (userCount > 1) + ? _t("%(severalUsers)sleft and rejoined %(count)s times", { severalUsers: "", count }) + : _t("%(oneUser)sleft and rejoined %(count)s times", { oneUser: "", count }); + break; + case TransitionType.InviteReject: + res = (userCount > 1) + ? _t("%(severalUsers)srejected their invitations %(count)s times", { + severalUsers: "", + count, + }) + : _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count }); + break; + case TransitionType.InviteWithdrawal: + res = (userCount > 1) + ? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", { + severalUsers: "", + count, + }) + : _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count }); + break; + case TransitionType.Invited: + res = (userCount > 1) + ? _t("were invited %(count)s times", { count }) + : _t("was invited %(count)s times", { count }); + break; + case TransitionType.Banned: + res = (userCount > 1) + ? _t("were banned %(count)s times", { count }) + : _t("was banned %(count)s times", { count }); + break; + case TransitionType.Unbanned: + res = (userCount > 1) + ? _t("were unbanned %(count)s times", { count }) + : _t("was unbanned %(count)s times", { count }); + break; + case TransitionType.Kicked: + res = (userCount > 1) + ? _t("were removed %(count)s times", { count }) + : _t("was removed %(count)s times", { count }); + break; + case TransitionType.ChangedName: + res = (userCount > 1) + ? _t("%(severalUsers)schanged their name %(count)s times", { severalUsers: "", count }) + : _t("%(oneUser)schanged their name %(count)s times", { oneUser: "", count }); + break; + case TransitionType.ChangedAvatar: + res = (userCount > 1) + ? _t("%(severalUsers)schanged their avatar %(count)s times", { severalUsers: "", count }) + : _t("%(oneUser)schanged their avatar %(count)s times", { oneUser: "", count }); + break; + case TransitionType.NoChange: + res = (userCount > 1) + ? _t("%(severalUsers)smade no changes %(count)s times", { severalUsers: "", count }) + : _t("%(oneUser)smade no changes %(count)s times", { oneUser: "", count }); + break; + case TransitionType.ServerAcl: + res = (userCount > 1) + ? _t("%(severalUsers)schanged the server ACLs %(count)s times", + { severalUsers: "", count }) + : _t("%(oneUser)schanged the server ACLs %(count)s times", { oneUser: "", count }); + break; + case TransitionType.ChangedPins: + res = (userCount > 1) + ? _t("%(severalUsers)schanged the pinned messages for the room %(count)s times.", + { severalUsers: "", count }, + { + "a": (sub) => + { sub } + , + }) + : _t("%(oneUser)schanged the pinned messages for the room %(count)s times.", + { oneUser: "", count }, + { + "a": (sub) => + { sub } + , + }); + break; + case TransitionType.MessageRemoved: + res = (userCount > 1) + ? _t("%(severalUsers)sremoved a message %(count)s times", + { severalUsers: "", count }) + : _t("%(oneUser)sremoved a message %(count)s times", { oneUser: "", count }); + break; + case TransitionType.HiddenEvent: + res = (userCount > 1) + ? _t("%(severalUsers)ssent %(count)s hidden messages", + { severalUsers: "", count }) + : _t("%(oneUser)ssent %(count)s hidden messages", { oneUser: "", count }); + break; + } + + return res; + } + + private static getTransitionSequence(events: IUserEvents[]) { + return events.map(EventListSummary.getTransition); + } + + /** + * Label a given membership event, `e`, where `getContent().membership` has + * changed for each transition allowed by the Matrix protocol. This attempts to + * label the membership changes that occur in `../../../TextForEvent.js`. + * @param {MatrixEvent} e the membership change event to label. + * @returns {string?} the transition type given to this event. This defaults to `null` + * if a transition is not recognised. + */ + private static getTransition(e: IUserEvents): TransitionType { + if (e.mxEvent.isRedacted()) { + return TransitionType.MessageRemoved; + } + + switch (e.mxEvent.getType()) { + case EventType.RoomThirdPartyInvite: + // Handle 3pid invites the same as invites so they get bundled together + if (!isValid3pidInvite(e.mxEvent)) { + return TransitionType.InviteWithdrawal; + } + return TransitionType.Invited; + + case EventType.RoomServerAcl: + return TransitionType.ServerAcl; + + case EventType.RoomPinnedEvents: + return TransitionType.ChangedPins; + + case EventType.RoomMember: + switch (e.mxEvent.getContent().membership) { + case 'invite': return TransitionType.Invited; + case 'ban': return TransitionType.Banned; + case 'join': + if (e.mxEvent.getPrevContent().membership === 'join') { + if (e.mxEvent.getContent().displayname !== e.mxEvent.getPrevContent().displayname) { + return TransitionType.ChangedName; + } else if (e.mxEvent.getContent().avatar_url !== e.mxEvent.getPrevContent().avatar_url) { + return TransitionType.ChangedAvatar; + } + // console.log("MELS ignoring duplicate membership join event"); + return TransitionType.NoChange; + } else { + return TransitionType.Joined; + } + case 'leave': + if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) { + if (e.mxEvent.getPrevContent().membership === "invite") { + return TransitionType.InviteReject; + } + return TransitionType.Left; + } + switch (e.mxEvent.getPrevContent().membership) { + case 'invite': return TransitionType.InviteWithdrawal; + case 'ban': return TransitionType.Unbanned; + // sender is not target and made the target leave, if not from invite/ban then this is a kick + default: return TransitionType.Kicked; + } + default: return null; + } + + default: + // otherwise, assume this is a hidden event + return TransitionType.HiddenEvent; + } + } + + getAggregate(userEvents: Record) { + // A map of aggregate type to arrays of display names. Each aggregate type + // is a comma-delimited string of transitions, e.g. "joined,left,kicked". + // The array of display names is the array of users who went through that + // sequence during eventsToRender. + const aggregate: Record = { + // $aggregateType : []:string + }; + // A map of aggregate types to the indices that order them (the index of + // the first event for a given transition sequence) + const aggregateIndices: Record = { + // $aggregateType : int + }; + + const users = Object.keys(userEvents); + users.forEach( + (userId) => { + const firstEvent = userEvents[userId][0]; + const displayName = firstEvent.displayName; + + const seq = EventListSummary.getTransitionSequence(userEvents[userId]).join(SEP); + if (!aggregate[seq]) { + aggregate[seq] = []; + aggregateIndices[seq] = -1; + } + + aggregate[seq].push(displayName); + + if (aggregateIndices[seq] === -1 || + firstEvent.index < aggregateIndices[seq] + ) { + aggregateIndices[seq] = firstEvent.index; + } + }, + ); + + return { + names: aggregate, + indices: aggregateIndices, + }; + } + + render() { + const eventsToRender = this.props.events; + + // Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created, + // so this works perfectly for us to match event order whilst storing the latest Avatar Member + const latestUserAvatarMember = new Map(); + + // Object mapping user IDs to an array of IUserEvents + const userEvents: Record = {}; + eventsToRender.forEach((e, index) => { + const type = e.getType(); + const userId = type === EventType.RoomServerAcl ? e.getSender() : e.getStateKey(); + // Initialise a user's events + if (!userEvents[userId]) { + userEvents[userId] = []; + } + + if (e.target && TARGET_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { + latestUserAvatarMember.set(userId, e.target); + } else { + latestUserAvatarMember.set(userId, e.sender); + } + + let displayName = userId; + if (type === EventType.RoomThirdPartyInvite) { + displayName = e.getContent().display_name; + } else if (e.target && TARGET_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { + displayName = e.target.name; + } else { + displayName = e.sender.name; + } + + userEvents[userId].push({ + mxEvent: e, + displayName, + index: index, + }); + }); + + const aggregate = this.getAggregate(userEvents); + + // Sort types by order of lowest event index within sequence + const orderedTransitionSequences = Object.keys(aggregate.names).sort( + (seq1, seq2) => aggregate.indices[seq1] - aggregate.indices[seq2], + ); + + return ; + } +} diff --git a/src/components/views/elements/GenericEventListSummary.tsx b/src/components/views/elements/GenericEventListSummary.tsx new file mode 100644 index 0000000000..9620d9f590 --- /dev/null +++ b/src/components/views/elements/GenericEventListSummary.tsx @@ -0,0 +1,118 @@ +/* +Copyright 2019 - 2022 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, { ReactNode, useEffect } from "react"; +import { uniqBy } from "lodash"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { logger } from "matrix-js-sdk/src/logger"; + +import MemberAvatar from '../avatars/MemberAvatar'; +import { _t } from '../../../languageHandler'; +import { useStateToggle } from "../../../hooks/useStateToggle"; +import AccessibleButton from "./AccessibleButton"; +import { Layout } from '../../../settings/enums/Layout'; + +interface IProps { + // An array of member events to summarise + events: MatrixEvent[]; + // The minimum number of events needed to trigger summarisation + threshold?: number; + // Whether or not to begin with state.expanded=true + startExpanded?: boolean; + // The list of room members for which to show avatars next to the summary + summaryMembers?: RoomMember[]; + // The text to show as the summary of this event list + summaryText?: string | JSX.Element; + // An array of EventTiles to render when expanded + children: ReactNode[]; + // Called when the event list expansion is toggled + onToggle?(): void; + // The layout currently used + layout?: Layout; +} + +const GenericEventListSummary: React.FC = ({ + events, + children, + threshold = 3, + onToggle, + startExpanded = false, + summaryMembers = [], + summaryText, + layout = Layout.Group, +}) => { + const [expanded, toggleExpanded] = useStateToggle(startExpanded); + + // Whenever expanded changes call onToggle + useEffect(() => { + if (onToggle) { + onToggle(); + } + }, [expanded]); // eslint-disable-line react-hooks/exhaustive-deps + + const eventIds = events.map((e) => e.getId()).join(','); + + // If we are only given few events then just pass them through + if (events.length < threshold) { + return ( +
  • + { children } +
  • + ); + } + + let body; + if (expanded) { + body = +
     
    + { children } +
    ; + } else { + const uniqueMembers = uniqBy(summaryMembers.filter(member => { + if (!member?.getMxcAvatarUrl) { + logger.error("EventListSummary given null summaryMember, termites may be afoot eating event senders", + summaryMembers); + return false; + } + return true; + }), member => member.getMxcAvatarUrl()); + const avatars = uniqueMembers.map((m) => ); + body = ( +
    +
    + + { avatars } + + + { summaryText } + +
    +
    + ); + } + + return ( +
  • + + { expanded ? _t('collapse') : _t('expand') } + + { body } +
  • + ); +}; + +export default GenericEventListSummary; diff --git a/src/components/views/elements/MemberEventListSummary.tsx b/src/components/views/elements/MemberEventListSummary.tsx deleted file mode 100644 index 8065a439dd..0000000000 --- a/src/components/views/elements/MemberEventListSummary.tsx +++ /dev/null @@ -1,503 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> - -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, { ComponentProps } from 'react'; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import { EventType } from 'matrix-js-sdk/src/@types/event'; - -import { _t } from '../../../languageHandler'; -import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; -import { isValid3pidInvite } from "../../../RoomInvite"; -import EventListSummary from "./EventListSummary"; -import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { RightPanelPhases } from '../../../stores/right-panel/RightPanelStorePhases'; -import { jsxJoin } from '../../../utils/ReactUtils'; -import { Layout } from '../../../settings/enums/Layout'; -import RightPanelStore from '../../../stores/right-panel/RightPanelStore'; -import AccessibleButton from './AccessibleButton'; - -const onPinnedMessagesClick = (): void => { - RightPanelStore.instance.setCard({ phase: RightPanelPhases.PinnedMessages }, false); -}; - -const SENDER_AS_DISPLAY_NAME_EVENTS = [EventType.RoomServerAcl, EventType.RoomPinnedEvents]; - -interface IProps extends Omit, "summaryText" | "summaryMembers"> { - // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left" - summaryLength?: number; - // The maximum number of avatars to display in the summary - avatarsMaxLength?: number; - // The currently selected layout - layout: Layout; -} - -interface IUserEvents { - // The original event - mxEvent: MatrixEvent; - // The display name of the user (if not, then user ID) - displayName: string; - // The original index of the event in this.props.events - index: number; -} - -enum TransitionType { - Joined = "joined", - Left = "left", - JoinedAndLeft = "joined_and_left", - LeftAndJoined = "left_and_joined", - InviteReject = "invite_reject", - InviteWithdrawal = "invite_withdrawal", - Invited = "invited", - Banned = "banned", - Unbanned = "unbanned", - Kicked = "kicked", - ChangedName = "changed_name", - ChangedAvatar = "changed_avatar", - NoChange = "no_change", - ServerAcl = "server_acl", - ChangedPins = "pinned_messages" -} - -const SEP = ","; - -@replaceableComponent("views.elements.MemberEventListSummary") -export default class MemberEventListSummary extends React.Component { - static defaultProps = { - summaryLength: 1, - threshold: 3, - avatarsMaxLength: 5, - layout: Layout.Group, - }; - - shouldComponentUpdate(nextProps: IProps): boolean { - // 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 || - nextProps.layout !== this.props.layout - ); - } - - /** - * Generate the text for users aggregated by their transition sequences (`eventAggregates`) where - * the sequences are ordered by `orderedTransitionSequences`. - * @param {object} eventAggregates a map of transition sequence to array of user display names - * or user IDs. - * @param {string[]} orderedTransitionSequences an array which is some ordering of - * `Object.keys(eventAggregates)`. - * @returns {string} the textual summary of the aggregated events that occurred. - */ - private generateSummary( - eventAggregates: Record, - orderedTransitionSequences: string[], - ): string | JSX.Element { - const summaries = orderedTransitionSequences.map((transitions) => { - const userNames = eventAggregates[transitions]; - const nameList = this.renderNameList(userNames); - - const splitTransitions = transitions.split(SEP) as TransitionType[]; - - // Some neighbouring transitions are common, so canonicalise some into "pair" - // transitions - const canonicalTransitions = MemberEventListSummary.getCanonicalTransitions(splitTransitions); - // Transform into consecutive repetitions of the same transition (like 5 - // consecutive 'joined_and_left's) - const coalescedTransitions = MemberEventListSummary.coalesceRepeatedTransitions(canonicalTransitions); - - const descs = coalescedTransitions.map((t) => { - return MemberEventListSummary.getDescriptionForTransition( - t.transitionType, userNames.length, t.repeats, - ); - }); - - const desc = formatCommaSeparatedList(descs); - - return _t('%(nameList)s %(transitionList)s', { nameList, transitionList: desc }); - }); - - if (!summaries) { - return null; - } - - return jsxJoin(summaries, ", "); - } - - /** - * @param {string[]} users an array of user display names or user IDs. - * @returns {string} a comma-separated list that ends with "and [n] others" if there are - * more items in `users` than `this.props.summaryLength`, which is the number of names - * included before "and [n] others". - */ - private renderNameList(users: string[]) { - return formatCommaSeparatedList(users, this.props.summaryLength); - } - - /** - * Canonicalise an array of transitions such that some pairs of transitions become - * single transitions. For example an input ['joined','left'] would result in an output - * ['joined_and_left']. - * @param {string[]} transitions an array of transitions. - * @returns {string[]} an array of transitions. - */ - private static getCanonicalTransitions(transitions: TransitionType[]): TransitionType[] { - const modMap = { - [TransitionType.Joined]: { - after: TransitionType.Left, - newTransition: TransitionType.JoinedAndLeft, - }, - [TransitionType.Left]: { - after: TransitionType.Joined, - newTransition: TransitionType.LeftAndJoined, - }, - // $currentTransition : { - // 'after' : $nextTransition, - // 'newTransition' : 'new_transition_type', - // }, - }; - const res: TransitionType[] = []; - - for (let i = 0; i < transitions.length; i++) { - const t = transitions[i]; - const t2 = transitions[i + 1]; - - let transition = t; - - if (i < transitions.length - 1 && modMap[t] && modMap[t].after === t2) { - transition = modMap[t].newTransition; - i++; - } - - res.push(transition); - } - return res; - } - - /** - * Transform an array of transitions into an array of transitions and how many times - * they are repeated consecutively. - * - * An array of 123 "joined_and_left" transitions, would result in: - * ``` - * [{ - * transitionType: "joined_and_left" - * repeats: 123 - * }] - * ``` - * @param {string[]} transitions the array of transitions to transform. - * @returns {object[]} an array of coalesced transitions. - */ - private static coalesceRepeatedTransitions(transitions: TransitionType[]) { - const res: { - transitionType: TransitionType; - repeats: number; - }[] = []; - - for (let i = 0; i < transitions.length; i++) { - if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) { - res[res.length - 1].repeats += 1; - } else { - res.push({ - transitionType: transitions[i], - repeats: 1, - }); - } - } - return res; - } - - /** - * For a certain transition, t, describe what happened to the users that - * underwent the transition. - * @param {string} t the transition type. - * @param {number} userCount number of usernames - * @param {number} repeats the number of times the transition was repeated in a row. - * @returns {string} the written Human Readable equivalent of the transition. - */ - private static getDescriptionForTransition( - t: TransitionType, - userCount: number, - repeats: number, - ): string | JSX.Element { - // The empty interpolations 'severalUsers' and 'oneUser' - // are there only to show translators to non-English languages - // that the verb is conjugated to plural or singular Subject. - let res = null; - switch (t) { - case "joined": - res = (userCount > 1) - ? _t("%(severalUsers)sjoined %(count)s times", { severalUsers: "", count: repeats }) - : _t("%(oneUser)sjoined %(count)s times", { oneUser: "", count: repeats }); - break; - case "left": - res = (userCount > 1) - ? _t("%(severalUsers)sleft %(count)s times", { severalUsers: "", count: repeats }) - : _t("%(oneUser)sleft %(count)s times", { oneUser: "", count: repeats }); - break; - case "joined_and_left": - res = (userCount > 1) - ? _t("%(severalUsers)sjoined and left %(count)s times", { severalUsers: "", count: repeats }) - : _t("%(oneUser)sjoined and left %(count)s times", { oneUser: "", count: repeats }); - break; - case "left_and_joined": - res = (userCount > 1) - ? _t("%(severalUsers)sleft and rejoined %(count)s times", { severalUsers: "", count: repeats }) - : _t("%(oneUser)sleft and rejoined %(count)s times", { oneUser: "", count: repeats }); - break; - case "invite_reject": - res = (userCount > 1) - ? _t("%(severalUsers)srejected their invitations %(count)s times", { - severalUsers: "", - count: repeats, - }) - : _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count: repeats }); - break; - case "invite_withdrawal": - res = (userCount > 1) - ? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", { - severalUsers: "", - count: repeats, - }) - : _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count: repeats }); - break; - case "invited": - res = (userCount > 1) - ? _t("were invited %(count)s times", { count: repeats }) - : _t("was invited %(count)s times", { count: repeats }); - break; - case "banned": - res = (userCount > 1) - ? _t("were banned %(count)s times", { count: repeats }) - : _t("was banned %(count)s times", { count: repeats }); - break; - case "unbanned": - res = (userCount > 1) - ? _t("were unbanned %(count)s times", { count: repeats }) - : _t("was unbanned %(count)s times", { count: repeats }); - break; - case "kicked": - res = (userCount > 1) - ? _t("were removed %(count)s times", { count: repeats }) - : _t("was removed %(count)s times", { count: repeats }); - break; - case "changed_name": - res = (userCount > 1) - ? _t("%(severalUsers)schanged their name %(count)s times", { severalUsers: "", count: repeats }) - : _t("%(oneUser)schanged their name %(count)s times", { oneUser: "", count: repeats }); - break; - case "changed_avatar": - res = (userCount > 1) - ? _t("%(severalUsers)schanged their avatar %(count)s times", { severalUsers: "", count: repeats }) - : _t("%(oneUser)schanged their avatar %(count)s times", { oneUser: "", count: repeats }); - break; - case "no_change": - res = (userCount > 1) - ? _t("%(severalUsers)smade no changes %(count)s times", { severalUsers: "", count: repeats }) - : _t("%(oneUser)smade no changes %(count)s times", { oneUser: "", count: repeats }); - break; - case "server_acl": - res = (userCount > 1) - ? _t("%(severalUsers)schanged the server ACLs %(count)s times", - { severalUsers: "", count: repeats }) - : _t("%(oneUser)schanged the server ACLs %(count)s times", { oneUser: "", count: repeats }); - break; - case "pinned_messages": - res = (userCount > 1) - ? _t("%(severalUsers)schanged the pinned messages for the room %(count)s times.", - { severalUsers: "", count: repeats }, - { - "a": (sub) => - { sub } - , - }) - : _t("%(oneUser)schanged the pinned messages for the room %(count)s times.", - { oneUser: "", count: repeats }, - { - "a": (sub) => - { sub } - , - }); - break; - } - - return res; - } - - private static getTransitionSequence(events: IUserEvents[]) { - return events.map(MemberEventListSummary.getTransition); - } - - /** - * Label a given membership event, `e`, where `getContent().membership` has - * changed for each transition allowed by the Matrix protocol. This attempts to - * label the membership changes that occur in `../../../TextForEvent.js`. - * @param {MatrixEvent} e the membership change event to label. - * @returns {string?} the transition type given to this event. This defaults to `null` - * if a transition is not recognised. - */ - private static getTransition(e: IUserEvents): TransitionType { - const type = e.mxEvent.getType(); - - if (type === EventType.RoomThirdPartyInvite) { - // Handle 3pid invites the same as invites so they get bundled together - if (!isValid3pidInvite(e.mxEvent)) { - return TransitionType.InviteWithdrawal; - } - return TransitionType.Invited; - } else if (type === EventType.RoomServerAcl) { - return TransitionType.ServerAcl; - } else if (type === EventType.RoomPinnedEvents) { - return TransitionType.ChangedPins; - } - - switch (e.mxEvent.getContent().membership) { - case 'invite': return TransitionType.Invited; - case 'ban': return TransitionType.Banned; - case 'join': - if (e.mxEvent.getPrevContent().membership === 'join') { - if (e.mxEvent.getContent().displayname !== - e.mxEvent.getPrevContent().displayname) { - return TransitionType.ChangedName; - } else if (e.mxEvent.getContent().avatar_url !== - e.mxEvent.getPrevContent().avatar_url) { - return TransitionType.ChangedAvatar; - } - // console.log("MELS ignoring duplicate membership join event"); - return TransitionType.NoChange; - } else { - return TransitionType.Joined; - } - case 'leave': - if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) { - switch (e.mxEvent.getPrevContent().membership) { - case 'invite': return TransitionType.InviteReject; - default: return TransitionType.Left; - } - } - switch (e.mxEvent.getPrevContent().membership) { - case 'invite': return TransitionType.InviteWithdrawal; - case 'ban': return TransitionType.Unbanned; - // sender is not target and made the target leave, if not from invite/ban then this is a kick - default: return TransitionType.Kicked; - } - default: return null; - } - } - - getAggregate(userEvents: Record) { - // A map of aggregate type to arrays of display names. Each aggregate type - // is a comma-delimited string of transitions, e.g. "joined,left,kicked". - // The array of display names is the array of users who went through that - // sequence during eventsToRender. - const aggregate: Record = { - // $aggregateType : []:string - }; - // A map of aggregate types to the indices that order them (the index of - // the first event for a given transition sequence) - const aggregateIndices: Record = { - // $aggregateType : int - }; - - const users = Object.keys(userEvents); - users.forEach( - (userId) => { - const firstEvent = userEvents[userId][0]; - const displayName = firstEvent.displayName; - - const seq = MemberEventListSummary.getTransitionSequence(userEvents[userId]).join(SEP); - if (!aggregate[seq]) { - aggregate[seq] = []; - aggregateIndices[seq] = -1; - } - - aggregate[seq].push(displayName); - - if (aggregateIndices[seq] === -1 || - firstEvent.index < aggregateIndices[seq] - ) { - aggregateIndices[seq] = firstEvent.index; - } - }, - ); - - return { - names: aggregate, - indices: aggregateIndices, - }; - } - - render() { - const eventsToRender = this.props.events; - - // Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created, - // so this works perfectly for us to match event order whilst storing the latest Avatar Member - const latestUserAvatarMember = new Map(); - - // Object mapping user IDs to an array of IUserEvents - const userEvents: Record = {}; - eventsToRender.forEach((e, index) => { - const type = e.getType(); - const userId = type === EventType.RoomServerAcl ? e.getSender() : e.getStateKey(); - // Initialise a user's events - if (!userEvents[userId]) { - userEvents[userId] = []; - } - - if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { - latestUserAvatarMember.set(userId, e.sender); - } else if (e.target) { - latestUserAvatarMember.set(userId, e.target); - } - - let displayName = userId; - if (type === EventType.RoomThirdPartyInvite) { - displayName = e.getContent().display_name; - } else if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { - displayName = e.sender.name; - } else if (e.target) { - displayName = e.target.name; - } - - userEvents[userId].push({ - mxEvent: e, - displayName, - index: index, - }); - }); - - const aggregate = this.getAggregate(userEvents); - - // Sort types by order of lowest event index within sequence - const orderedTransitionSequences = Object.keys(aggregate.names).sort( - (seq1, seq2) => aggregate.indices[seq1] - aggregate.indices[seq2], - ); - - return ; - } -} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index dacbc94d67..2ac3e27669 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2226,23 +2226,6 @@ "Backspace": "Backspace", "Join": "Join", "Please create a new issue on GitHub so that we can investigate this bug.": "Please create a new issue on GitHub so that we can investigate this bug.", - "collapse": "collapse", - "expand": "expand", - "Including you, %(commaSeparatedMembers)s": "Including you, %(commaSeparatedMembers)s", - "Including %(commaSeparatedMembers)s": "Including %(commaSeparatedMembers)s", - "View all %(count)s members|other": "View all %(count)s members", - "View all %(count)s members|one": "View 1 member", - "%(count)s members including you, %(commaSeparatedMembers)s|other": "%(count)s members including you, %(commaSeparatedMembers)s", - "%(count)s members including you, %(commaSeparatedMembers)s|zero": "You", - "%(count)s members including you, %(commaSeparatedMembers)s|one": "%(count)s members including you and %(commaSeparatedMembers)s", - "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s members including %(commaSeparatedMembers)s", - "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", - "%(count)s people you know have already joined|other": "%(count)s people you know have already joined", - "%(count)s people you know have already joined|one": "%(count)s person you know has already joined", - "Rotate Left": "Rotate Left", - "Rotate Right": "Rotate Right", - "Information": "Information", - "Language Dropdown": "Language Dropdown", "%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s", "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times", "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)sjoined", @@ -2301,7 +2284,33 @@ "%(oneUser)schanged the server ACLs %(count)s times|other": "%(oneUser)schanged the server ACLs %(count)s times", "%(oneUser)schanged the server ACLs %(count)s times|one": "%(oneUser)schanged the server ACLs", "%(severalUsers)schanged the pinned messages for the room %(count)s times.|other": "%(severalUsers)schanged the pinned messages for the room %(count)s times.", + "%(severalUsers)schanged the pinned messages for the room %(count)s times.|one": "%(severalUsers)schanged the pinned messages for the room.", "%(oneUser)schanged the pinned messages for the room %(count)s times.|other": "%(oneUser)schanged the pinned messages for the room %(count)s times.", + "%(severalUsers)sremoved a message %(count)s times|other": "%(severalUsers)sremoved %(count)s messages", + "%(severalUsers)sremoved a message %(count)s times|one": "%(severalUsers)sremoved a message", + "%(oneUser)sremoved a message %(count)s times|other": "%(oneUser)sremoved %(count)s messages", + "%(oneUser)sremoved a message %(count)s times|one": "%(oneUser)sremoved a message", + "%(severalUsers)ssent %(count)s hidden messages|other": "%(severalUsers)ssent %(count)s hidden messages", + "%(severalUsers)ssent %(count)s hidden messages|one": "%(severalUsers)ssent %(count)s hidden messages", + "%(oneUser)ssent %(count)s hidden messages|other": "%(oneUser)ssent a hidden message", + "%(oneUser)ssent %(count)s hidden messages|one": "%(oneUser)ssent a hidden message", + "Including you, %(commaSeparatedMembers)s": "Including you, %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Including %(commaSeparatedMembers)s", + "View all %(count)s members|other": "View all %(count)s members", + "View all %(count)s members|one": "View 1 member", + "%(count)s members including you, %(commaSeparatedMembers)s|other": "%(count)s members including you, %(commaSeparatedMembers)s", + "%(count)s members including you, %(commaSeparatedMembers)s|zero": "You", + "%(count)s members including you, %(commaSeparatedMembers)s|one": "%(count)s members including you and %(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s members including %(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s people you know have already joined|other": "%(count)s people you know have already joined", + "%(count)s people you know have already joined|one": "%(count)s person you know has already joined", + "collapse": "collapse", + "expand": "expand", + "Rotate Left": "Rotate Left", + "Rotate Right": "Rotate Right", + "Information": "Information", + "Language Dropdown": "Language Dropdown", "Create poll": "Create poll", "Create Poll": "Create Poll", "Failed to post poll": "Failed to post poll", @@ -3056,10 +3065,6 @@ "Logout": "Logout", "%(creator)s created this DM.": "%(creator)s created this DM.", "%(creator)s created and configured the room.": "%(creator)s created and configured the room.", - "%(count)s messages deleted.|other": "%(count)s messages deleted.", - "%(count)s messages deleted.|one": "%(count)s message deleted.", - "%(count)s hidden messages.|other": "%(count)s hidden messages.", - "%(count)s hidden messages.|one": "%(count)s hidden message.", "Your Communities": "Your Communities", "Did you know: you can use communities to filter your %(brand)s experience!": "Did you know: you can use communities to filter your %(brand)s experience!", "You can click on an avatar in the filter panel at any time to see only the rooms and people associated with that community.": "You can click on an avatar in the filter panel at any time to see only the rooms and people associated with that community.", diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index 0e4042368b..0681f0ff6e 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -122,8 +122,7 @@ describe('MessagePanel', function() { return events; } - // make a collection of events with some member events that should be collapsed - // with a MemberEventListSummary + // make a collection of events with some member events that should be collapsed with an EventListSummary function mkMelsEvents() { const events = []; const ts0 = Date.now(); @@ -304,7 +303,7 @@ describe('MessagePanel', function() { expect(tiles.length).toEqual(2); const summaryTiles = TestUtils.scryRenderedComponentsWithType( - res, sdk.getComponent('elements.MemberEventListSummary'), + res, sdk.getComponent('elements.EventListSummary'), ); expect(summaryTiles.length).toEqual(1); }); @@ -341,7 +340,7 @@ describe('MessagePanel', function() { />, ); - const summary = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_EventListSummary'); + const summary = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_GenericEventListSummary'); // find the
  • which wraps the read marker const rm = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_RoomView_myReadMarker_container'); @@ -363,7 +362,7 @@ describe('MessagePanel', function() { />, ); - const summary = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_EventListSummary'); + const summary = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_GenericEventListSummary'); // find the
  • which wraps the read marker const rm = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_RoomView_myReadMarker_container'); @@ -450,7 +449,7 @@ describe('MessagePanel', function() { expect(tiles.at(0).props().mxEvent.getType()).toEqual("m.room.create"); expect(tiles.at(1).props().mxEvent.getType()).toEqual("m.room.encryption"); - const summaryTiles = res.find(sdk.getComponent('views.elements.EventListSummary')); + const summaryTiles = res.find(sdk.getComponent('views.elements.GenericEventListSummary')); const summaryTile = summaryTiles.at(0); const summaryEventTiles = summaryTile.find(sdk.getComponent('views.rooms.EventTile')); diff --git a/test/components/views/elements/MemberEventListSummary-test.js b/test/components/views/elements/EventListSummary-test.js similarity index 93% rename from test/components/views/elements/MemberEventListSummary-test.js rename to test/components/views/elements/EventListSummary-test.js index 8eee681ffe..0fb21ecced 100644 --- a/test/components/views/elements/MemberEventListSummary-test.js +++ b/test/components/views/elements/EventListSummary-test.js @@ -5,12 +5,10 @@ import ShallowRenderer from "react-test-renderer/shallow"; import sdk from '../../../skinned-sdk'; import * as testUtils from '../../../test-utils'; -// Give MELS a matrixClient in its child context -const MemberEventListSummary = testUtils.wrapInMatrixClientContext( - sdk.getComponent('views.elements.MemberEventListSummary'), -); +// Give ELS a matrixClient in its child context +const EventListSummary = testUtils.wrapInMatrixClientContext(sdk.getComponent('views.elements.EventListSummary')); -describe('MemberEventListSummary', function() { +describe('EventListSummary', function() { // Generate dummy event tiles for use in simulating an expanded MELS const generateTiles = (events) => { return events.map((e) => { @@ -102,7 +100,7 @@ describe('MemberEventListSummary', function() { }; const renderer = new ShallowRenderer(); - renderer.render(); + renderer.render(); const wrapper = renderer.getRenderOutput(); // matrix cli context wrapper const result = wrapper.props.children; @@ -125,7 +123,7 @@ describe('MemberEventListSummary', function() { }; const renderer = new ShallowRenderer(); - renderer.render(); + renderer.render(); const wrapper = renderer.getRenderOutput(); // matrix cli context wrapper const result = wrapper.props.children; @@ -150,10 +148,10 @@ describe('MemberEventListSummary', function() { }; const instance = ReactTestUtils.renderIntoDocument( - , + , ); const summary = ReactTestUtils.findRenderedDOMComponentWithClass( - instance, "mx_EventListSummary_summary", + instance, "mx_GenericEventListSummary_summary", ); const summaryText = summary.textContent; @@ -186,10 +184,10 @@ describe('MemberEventListSummary', function() { }; const instance = ReactTestUtils.renderIntoDocument( - , + , ); const summary = ReactTestUtils.findRenderedDOMComponentWithClass( - instance, "mx_EventListSummary_summary", + instance, "mx_GenericEventListSummary_summary", ); const summaryText = summary.textContent; @@ -234,10 +232,10 @@ describe('MemberEventListSummary', function() { }; const instance = ReactTestUtils.renderIntoDocument( - , + , ); const summary = ReactTestUtils.findRenderedDOMComponentWithClass( - instance, "mx_EventListSummary_summary", + instance, "mx_GenericEventListSummary_summary", ); const summaryText = summary.textContent; @@ -286,10 +284,10 @@ describe('MemberEventListSummary', function() { }; const instance = ReactTestUtils.renderIntoDocument( - , + , ); const summary = ReactTestUtils.findRenderedDOMComponentWithClass( - instance, "mx_EventListSummary_summary", + instance, "mx_GenericEventListSummary_summary", ); const summaryText = summary.textContent; @@ -345,10 +343,10 @@ describe('MemberEventListSummary', function() { }; const instance = ReactTestUtils.renderIntoDocument( - , + , ); const summary = ReactTestUtils.findRenderedDOMComponentWithClass( - instance, "mx_EventListSummary_summary", + instance, "mx_GenericEventListSummary_summary", ); const summaryText = summary.textContent; @@ -383,10 +381,10 @@ describe('MemberEventListSummary', function() { }; const instance = ReactTestUtils.renderIntoDocument( - , + , ); const summary = ReactTestUtils.findRenderedDOMComponentWithClass( - instance, "mx_EventListSummary_summary", + instance, "mx_GenericEventListSummary_summary", ); const summaryText = summary.textContent; @@ -433,10 +431,10 @@ describe('MemberEventListSummary', function() { }; const instance = ReactTestUtils.renderIntoDocument( - , + , ); const summary = ReactTestUtils.findRenderedDOMComponentWithClass( - instance, "mx_EventListSummary_summary", + instance, "mx_GenericEventListSummary_summary", ); const summaryText = summary.textContent; @@ -507,10 +505,10 @@ describe('MemberEventListSummary', function() { }; const instance = ReactTestUtils.renderIntoDocument( - , + , ); const summary = ReactTestUtils.findRenderedDOMComponentWithClass( - instance, "mx_EventListSummary_summary", + instance, "mx_GenericEventListSummary_summary", ); const summaryText = summary.textContent; @@ -554,10 +552,10 @@ describe('MemberEventListSummary', function() { }; const instance = ReactTestUtils.renderIntoDocument( - , + , ); const summary = ReactTestUtils.findRenderedDOMComponentWithClass( - instance, "mx_EventListSummary_summary", + instance, "mx_GenericEventListSummary_summary", ); const summaryText = summary.textContent; @@ -589,10 +587,10 @@ describe('MemberEventListSummary', function() { }; const instance = ReactTestUtils.renderIntoDocument( - , + , ); const summary = ReactTestUtils.findRenderedDOMComponentWithClass( - instance, "mx_EventListSummary_summary", + instance, "mx_GenericEventListSummary_summary", ); const summaryText = summary.textContent; @@ -617,10 +615,10 @@ describe('MemberEventListSummary', function() { }; const instance = ReactTestUtils.renderIntoDocument( - , + , ); const summary = ReactTestUtils.findRenderedDOMComponentWithClass( - instance, "mx_EventListSummary_summary", + instance, "mx_GenericEventListSummary_summary", ); const summaryText = summary.textContent; @@ -644,10 +642,10 @@ describe('MemberEventListSummary', function() { }; const instance = ReactTestUtils.renderIntoDocument( - , + , ); const summary = ReactTestUtils.findRenderedDOMComponentWithClass( - instance, "mx_EventListSummary_summary", + instance, "mx_GenericEventListSummary_summary", ); const summaryText = summary.textContent; @@ -669,10 +667,10 @@ describe('MemberEventListSummary', function() { }; const instance = ReactTestUtils.renderIntoDocument( - , + , ); const summary = ReactTestUtils.findRenderedDOMComponentWithClass( - instance, "mx_EventListSummary_summary", + instance, "mx_GenericEventListSummary_summary", ); const summaryText = summary.textContent;