diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index d7db24d518..8e7073ebbe 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -17,15 +17,7 @@ limitations under the License. import React, { createRef, ReactNode, TransitionEvent } from "react"; import ReactDOM from "react-dom"; import classNames from "classnames"; -import { - Room, - MatrixClient, - RoomStateEvent, - EventStatus, - MatrixEvent, - EventType, - M_BEACON_INFO, -} from "matrix-js-sdk/src/matrix"; +import { Room, MatrixClient, RoomStateEvent, EventStatus, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { isSupportedReceiptType } from "matrix-js-sdk/src/utils"; import { Optional } from "matrix-events-sdk"; @@ -36,24 +28,17 @@ import { MatrixClientPeg } from "../../MatrixClientPeg"; import SettingsStore from "../../settings/SettingsStore"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; import { Layout } from "../../settings/enums/Layout"; -import { _t } from "../../languageHandler"; import EventTile, { GetRelationsForEvent, IReadReceiptProps, isEligibleForSpecialReceipt, UnwrappedEventTile, } from "../views/rooms/EventTile"; -import { hasText } from "../../TextForEvent"; import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer"; -import DMRoomMap from "../../utils/DMRoomMap"; -import NewRoomIntro from "../views/rooms/NewRoomIntro"; -import HistoryTile from "../views/rooms/HistoryTile"; import defaultDispatcher from "../../dispatcher/dispatcher"; import LegacyCallEventGrouper from "./LegacyCallEventGrouper"; import WhoIsTypingTile from "../views/rooms/WhoIsTypingTile"; import ScrollPanel, { IScrollState } from "./ScrollPanel"; -import GenericEventListSummary from "../views/elements/GenericEventListSummary"; -import EventListSummary from "../views/elements/EventListSummary"; import DateSeparator from "../views/messages/DateSeparator"; import ErrorBoundary from "../views/elements/ErrorBoundary"; import ResizeNotifier from "../../utils/ResizeNotifier"; @@ -66,16 +51,12 @@ import { IReadReceiptInfo } from "../views/rooms/ReadReceiptMarker"; import { haveRendererForEvent } from "../../events/EventTileFactory"; import { editorRoomKey } from "../../Editing"; import { hasThreadSummary } from "../../utils/EventUtils"; -import { VoiceBroadcastInfoEventType } from "../../voice-broadcast"; +import { BaseGrouper } from "./grouper/BaseGrouper"; +import { MainGrouper } from "./grouper/MainGrouper"; +import { CreationGrouper } from "./grouper/CreationGrouper"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; -const groupedStateEvents = [ - EventType.RoomMember, - EventType.RoomThirdPartyInvite, - EventType.RoomServerAcl, - EventType.RoomPinnedEvents, -]; // check if there is a previous event and it has the same sender as this event // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL @@ -1080,348 +1061,12 @@ export default class MessagePanel extends React.Component { * Simplifies threading of event context like whether it's the last successful event we sent which cannot be determined * by a consumer from the event alone, so has to be done by the event list processing code earlier. */ -interface WrappedEvent { +export interface WrappedEvent { event: MatrixEvent; shouldShow?: boolean; lastSuccessfulWeSent?: boolean; } -abstract class BaseGrouper { - public static canStartGroup = (_panel: MessagePanel, _ev: WrappedEvent): boolean => true; - - public events: WrappedEvent[] = []; - // events that we include in the group but then eject out and place above the group. - public ejectedEvents: WrappedEvent[] = []; - public readMarker: ReactNode; - - public constructor( - public readonly panel: MessagePanel, - public readonly firstEventAndShouldShow: WrappedEvent, - public readonly prevEvent: MatrixEvent | null, - public readonly lastShownEvent: MatrixEvent | undefined, - public readonly nextEvent: WrappedEvent | null, - public readonly nextEventTile?: MatrixEvent | null, - ) { - this.readMarker = panel.readMarkerForEvent( - firstEventAndShouldShow.event.getId()!, - firstEventAndShouldShow.event === lastShownEvent, - ); - } - - public abstract shouldGroup(ev: WrappedEvent): boolean; - public abstract add(ev: WrappedEvent): void; - public abstract getTiles(): ReactNode[]; - public abstract getNewPrevEvent(): MatrixEvent; -} - -/* Grouper classes determine when events can be grouped together in a summary. - * Groupers should have the following methods: - * - canStartGroup (static): determines if a new group should be started with the - * given event - * - shouldGroup: determines if the given event should be added to an existing group - * - add: adds an event to an existing group (should only be called if shouldGroup - * return true) - * - getTiles: returns the tiles that represent the group - * - getNewPrevEvent: returns the event that should be used as the new prevEvent - * when determining things such as whether a date separator is necessary - */ - -// 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, beacon_info event or membership event which is not regarding the sender of the `m.room.create` event -class CreationGrouper extends BaseGrouper { - public static canStartGroup = function (_panel: MessagePanel, { event }: WrappedEvent): boolean { - return event.getType() === EventType.RoomCreate; - }; - - public shouldGroup({ event, shouldShow }: WrappedEvent): boolean { - const panel = this.panel; - const createEvent = this.firstEventAndShouldShow.event; - if (!shouldShow) { - return true; - } - if (panel.wantsDateSeparator(this.firstEventAndShouldShow.event, event.getDate())) { - return false; - } - const eventType = event.getType(); - if ( - eventType === EventType.RoomMember && - (event.getStateKey() !== createEvent.getSender() || event.getContent()["membership"] !== "join") - ) { - return false; - } - - // beacons are not part of room creation configuration - // should be shown in timeline - if (M_BEACON_INFO.matches(eventType)) { - return false; - } - - if (VoiceBroadcastInfoEventType === eventType) { - // always show voice broadcast info events in timeline - return false; - } - - if (event.isState() && event.getSender() === createEvent.getSender()) { - return true; - } - - return false; - } - - public add(wrappedEvent: WrappedEvent): void { - const { event: ev, shouldShow } = wrappedEvent; - const panel = this.panel; - this.readMarker = this.readMarker || panel.readMarkerForEvent(ev.getId()!, ev === this.lastShownEvent); - if (!shouldShow) { - return; - } - if (ev.getType() === EventType.RoomEncryption) { - this.ejectedEvents.push(wrappedEvent); - } else { - this.events.push(wrappedEvent); - } - } - - public getTiles(): ReactNode[] { - // 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 panel = this.panel; - const ret: ReactNode[] = []; - const isGrouped = true; - const createEvent = this.firstEventAndShouldShow; - const lastShownEvent = this.lastShownEvent; - - if (panel.wantsDateSeparator(this.prevEvent, createEvent.event.getDate())) { - const ts = createEvent.event.getTs(); - ret.push( -
  • - -
  • , - ); - } - - // If this m.room.create event should be shown (room upgrade) then show it before the summary - if (createEvent.shouldShow) { - // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered - ret.push(...panel.getTilesForEvent(createEvent.event, createEvent)); - } - - for (const ejected of this.ejectedEvents) { - ret.push( - ...panel.getTilesForEvent(createEvent.event, ejected, createEvent.event === lastShownEvent, isGrouped), - ); - } - - const eventTiles = this.events - .map((e) => { - // In order to prevent DateSeparators from appearing in the expanded form - // 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.event, e, e.event === lastShownEvent, isGrouped); - }) - .reduce((a, b) => a.concat(b), []); - // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one - const ev = this.events[this.events.length - 1].event; - - let summaryText: string; - const roomId = ev.getRoomId(); - const creator = ev.sender?.name ?? ev.getSender(); - if (roomId && DMRoomMap.shared().getUserIdForRoomId(roomId)) { - summaryText = _t("timeline|creation_summary_dm", { creator }); - } else { - summaryText = _t("timeline|creation_summary_room", { creator }); - } - - ret.push(); - - ret.push( - e.event)} - onToggle={panel.onHeightChanged} // Update scroll state - summaryMembers={ev.sender ? [ev.sender] : undefined} - summaryText={summaryText} - layout={this.panel.props.layout} - > - {eventTiles} - , - ); - - if (this.readMarker) { - ret.push(this.readMarker); - } - - return ret; - } - - public getNewPrevEvent(): MatrixEvent { - return this.firstEventAndShouldShow.event; - } -} - -// Wrap consecutive grouped events in a ListSummary -class MainGrouper extends BaseGrouper { - public static canStartGroup = function (panel: MessagePanel, { event: ev, shouldShow }: WrappedEvent): boolean { - if (!shouldShow) return false; - - if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) { - return true; - } - - if (ev.isRedacted()) { - return true; - } - - if (panel.showHiddenEvents && !panel.shouldShowEvent(ev, true)) { - return true; - } - - return false; - }; - - public constructor( - public readonly panel: MessagePanel, - public readonly firstEventAndShouldShow: WrappedEvent, - public readonly prevEvent: MatrixEvent | null, - public readonly lastShownEvent: MatrixEvent | undefined, - nextEvent: WrappedEvent | null, - nextEventTile: MatrixEvent | null, - ) { - super(panel, firstEventAndShouldShow, prevEvent, lastShownEvent, nextEvent, nextEventTile); - this.events = [firstEventAndShouldShow]; - } - - public shouldGroup({ event: ev, shouldShow }: WrappedEvent): boolean { - if (!shouldShow) { - // 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].event, ev.getDate())) { - return false; - } - if (ev.isState() && groupedStateEvents.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(wrappedEvent: WrappedEvent): void { - const { event: ev, shouldShow } = wrappedEvent; - if (ev.getType() === EventType.RoomMember) { - // We can ignore any events that don't actually have a message to display - if (!hasText(ev, MatrixClientPeg.safeGet(), this.panel.showHiddenEvents)) return; - } - this.readMarker = this.readMarker || this.panel.readMarkerForEvent(ev.getId()!, ev === this.lastShownEvent); - if (!this.panel.showHiddenEvents && !shouldShow) { - // absorb hidden events to not split the summary - return; - } - this.events.push(wrappedEvent); - } - - private generateKey(): string { - return "eventlistsummary-" + this.events[0].event.getId(); - } - - public getTiles(): ReactNode[] { - // 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?.length) return []; - - const isGrouped = true; - const panel = this.panel; - const lastShownEvent = this.lastShownEvent; - const ret: ReactNode[] = []; - - if (panel.wantsDateSeparator(this.prevEvent, this.events[0].event.getDate())) { - const ts = this.events[0].event.getTs(); - ret.push( -
  • - -
  • , - ); - } - - // Ensure that the key of the EventListSummary does not change with new events in either direction. - // 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 keyEvent = this.events.find((e) => this.panel.grouperKeyMap.get(e.event)); - const key = - keyEvent && this.panel.grouperKeyMap.has(keyEvent.event) - ? this.panel.grouperKeyMap.get(keyEvent.event)! - : this.generateKey(); - if (!keyEvent) { - // Populate the weak map with the key. - // Note that we only set the key on the specific event it refers to, since this group might get - // split up in the future by other intervening events. If we were to set the key on all events - // currently in the group, we would risk later giving the same key to multiple groups. - this.panel.grouperKeyMap.set(this.events[0].event, key); - } - - let highlightInSummary = false; - let eventTiles: ReactNode[] | null = this.events - .map((e, i) => { - if (e.event.getId() === panel.props.highlightedEventId) { - highlightInSummary = true; - } - return panel.getTilesForEvent( - i === 0 ? this.prevEvent : this.events[i - 1].event, - e, - e.event === lastShownEvent, - isGrouped, - this.nextEvent, - this.nextEventTile, - ); - }) - .reduce((a, b) => a.concat(b), []); - - if (eventTiles.length === 0) { - eventTiles = null; - } - - // If a membership event is the start of visible history, tell the user - // why they can't see earlier messages - if (!this.panel.props.canBackPaginate && !this.prevEvent) { - ret.push(); - } - - ret.push( - e.event)} - onToggle={panel.onHeightChanged} // Update scroll state - startExpanded={highlightInSummary} - layout={this.panel.props.layout} - > - {eventTiles} - , - ); - - if (this.readMarker) { - ret.push(this.readMarker); - } - - return ret; - } - - public getNewPrevEvent(): MatrixEvent { - return this.events[this.events.length - 1].event; - } -} - // all the grouper classes that we use, ordered by priority const groupers = [CreationGrouper, MainGrouper]; diff --git a/src/components/structures/grouper/BaseGrouper.ts b/src/components/structures/grouper/BaseGrouper.ts new file mode 100644 index 0000000000..5821797ad5 --- /dev/null +++ b/src/components/structures/grouper/BaseGrouper.ts @@ -0,0 +1,59 @@ +/* +Copyright 2023 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 { ReactNode } from "react"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import MessagePanel, { WrappedEvent } from "../MessagePanel"; + +/* Grouper classes determine when events can be grouped together in a summary. + * Groupers should have the following methods: + * - canStartGroup (static): determines if a new group should be started with the + * given event + * - shouldGroup: determines if the given event should be added to an existing group + * - add: adds an event to an existing group (should only be called if shouldGroup + * return true) + * - getTiles: returns the tiles that represent the group + * - getNewPrevEvent: returns the event that should be used as the new prevEvent + * when determining things such as whether a date separator is necessary + */ +export abstract class BaseGrouper { + public static canStartGroup = (_panel: MessagePanel, _ev: WrappedEvent): boolean => true; + + public events: WrappedEvent[] = []; + // events that we include in the group but then eject out and place above the group. + public ejectedEvents: WrappedEvent[] = []; + public readMarker: ReactNode; + + public constructor( + public readonly panel: MessagePanel, + public readonly firstEventAndShouldShow: WrappedEvent, + public readonly prevEvent: MatrixEvent | null, + public readonly lastShownEvent: MatrixEvent | undefined, + public readonly nextEvent: WrappedEvent | null, + public readonly nextEventTile?: MatrixEvent | null, + ) { + this.readMarker = panel.readMarkerForEvent( + firstEventAndShouldShow.event.getId()!, + firstEventAndShouldShow.event === lastShownEvent, + ); + } + + public abstract shouldGroup(ev: WrappedEvent): boolean; + public abstract add(ev: WrappedEvent): void; + public abstract getTiles(): ReactNode[]; + public abstract getNewPrevEvent(): MatrixEvent; +} diff --git a/src/components/structures/grouper/CreationGrouper.tsx b/src/components/structures/grouper/CreationGrouper.tsx new file mode 100644 index 0000000000..b557188d15 --- /dev/null +++ b/src/components/structures/grouper/CreationGrouper.tsx @@ -0,0 +1,166 @@ +/* +Copyright 2023 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 } from "react"; +import { EventType, M_BEACON_INFO, MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { BaseGrouper } from "./BaseGrouper"; +import MessagePanel, { WrappedEvent } from "../MessagePanel"; +import { VoiceBroadcastInfoEventType } from "../../../voice-broadcast"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import { _t } from "../../../languageHandler"; +import DateSeparator from "../../views/messages/DateSeparator"; +import NewRoomIntro from "../../views/rooms/NewRoomIntro"; +import GenericEventListSummary from "../../views/elements/GenericEventListSummary"; + +// 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, beacon_info event or membership event which is not regarding the sender of the `m.room.create` event + +export class CreationGrouper extends BaseGrouper { + public static canStartGroup = function (_panel: MessagePanel, { event }: WrappedEvent): boolean { + return event.getType() === EventType.RoomCreate; + }; + + public shouldGroup({ event, shouldShow }: WrappedEvent): boolean { + const panel = this.panel; + const createEvent = this.firstEventAndShouldShow.event; + if (!shouldShow) { + return true; + } + if (panel.wantsDateSeparator(this.firstEventAndShouldShow.event, event.getDate())) { + return false; + } + const eventType = event.getType(); + if ( + eventType === EventType.RoomMember && + (event.getStateKey() !== createEvent.getSender() || event.getContent()["membership"] !== "join") + ) { + return false; + } + + // beacons are not part of room creation configuration + // should be shown in timeline + if (M_BEACON_INFO.matches(eventType)) { + return false; + } + + if (VoiceBroadcastInfoEventType === eventType) { + // always show voice broadcast info events in timeline + return false; + } + + if (event.isState() && event.getSender() === createEvent.getSender()) { + return true; + } + + return false; + } + + public add(wrappedEvent: WrappedEvent): void { + const { event: ev, shouldShow } = wrappedEvent; + const panel = this.panel; + this.readMarker = this.readMarker || panel.readMarkerForEvent(ev.getId()!, ev === this.lastShownEvent); + if (!shouldShow) { + return; + } + if (ev.getType() === EventType.RoomEncryption) { + this.ejectedEvents.push(wrappedEvent); + } else { + this.events.push(wrappedEvent); + } + } + + public getTiles(): ReactNode[] { + // 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 panel = this.panel; + const ret: ReactNode[] = []; + const isGrouped = true; + const createEvent = this.firstEventAndShouldShow; + const lastShownEvent = this.lastShownEvent; + + if (panel.wantsDateSeparator(this.prevEvent, createEvent.event.getDate())) { + const ts = createEvent.event.getTs(); + ret.push( +
  • + +
  • , + ); + } + + // If this m.room.create event should be shown (room upgrade) then show it before the summary + if (createEvent.shouldShow) { + // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered + ret.push(...panel.getTilesForEvent(createEvent.event, createEvent)); + } + + for (const ejected of this.ejectedEvents) { + ret.push( + ...panel.getTilesForEvent(createEvent.event, ejected, createEvent.event === lastShownEvent, isGrouped), + ); + } + + const eventTiles = this.events + .map((e) => { + // In order to prevent DateSeparators from appearing in the expanded form + // 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.event, e, e.event === lastShownEvent, isGrouped); + }) + .reduce((a, b) => a.concat(b), []); + // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one + const ev = this.events[this.events.length - 1].event; + + let summaryText: string; + const roomId = ev.getRoomId(); + const creator = ev.sender?.name ?? ev.getSender(); + if (roomId && DMRoomMap.shared().getUserIdForRoomId(roomId)) { + summaryText = _t("timeline|creation_summary_dm", { creator }); + } else { + summaryText = _t("timeline|creation_summary_room", { creator }); + } + + ret.push(); + + ret.push( + e.event)} + onToggle={panel.onHeightChanged} // Update scroll state + summaryMembers={ev.sender ? [ev.sender] : undefined} + summaryText={summaryText} + layout={this.panel.props.layout} + > + {eventTiles} + , + ); + + if (this.readMarker) { + ret.push(this.readMarker); + } + + return ret; + } + + public getNewPrevEvent(): MatrixEvent { + return this.firstEventAndShouldShow.event; + } +} diff --git a/src/components/structures/grouper/MainGrouper.tsx b/src/components/structures/grouper/MainGrouper.tsx new file mode 100644 index 0000000000..d6e8f33f2b --- /dev/null +++ b/src/components/structures/grouper/MainGrouper.tsx @@ -0,0 +1,192 @@ +/* +Copyright 2023 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 } from "react"; +import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import type MessagePanel from "../MessagePanel"; +import type { WrappedEvent } from "../MessagePanel"; +import { BaseGrouper } from "./BaseGrouper"; +import { hasText } from "../../../TextForEvent"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import DateSeparator from "../../views/messages/DateSeparator"; +import HistoryTile from "../../views/rooms/HistoryTile"; +import EventListSummary from "../../views/elements/EventListSummary"; + +const groupedStateEvents = [ + EventType.RoomMember, + EventType.RoomThirdPartyInvite, + EventType.RoomServerAcl, + EventType.RoomPinnedEvents, +]; + +// Wrap consecutive grouped events in a ListSummary +export class MainGrouper extends BaseGrouper { + public static canStartGroup = function (panel: MessagePanel, { event: ev, shouldShow }: WrappedEvent): boolean { + if (!shouldShow) return false; + + if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) { + return true; + } + + if (ev.isRedacted()) { + return true; + } + + if (panel.showHiddenEvents && !panel.shouldShowEvent(ev, true)) { + return true; + } + + return false; + }; + + public constructor( + public readonly panel: MessagePanel, + public readonly firstEventAndShouldShow: WrappedEvent, + public readonly prevEvent: MatrixEvent | null, + public readonly lastShownEvent: MatrixEvent | undefined, + nextEvent: WrappedEvent | null, + nextEventTile: MatrixEvent | null, + ) { + super(panel, firstEventAndShouldShow, prevEvent, lastShownEvent, nextEvent, nextEventTile); + this.events = [firstEventAndShouldShow]; + } + + public shouldGroup({ event: ev, shouldShow }: WrappedEvent): boolean { + if (!shouldShow) { + // 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].event, ev.getDate())) { + return false; + } + if (ev.isState() && groupedStateEvents.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(wrappedEvent: WrappedEvent): void { + const { event: ev, shouldShow } = wrappedEvent; + if (ev.getType() === EventType.RoomMember) { + // We can ignore any events that don't actually have a message to display + if (!hasText(ev, MatrixClientPeg.safeGet(), this.panel.showHiddenEvents)) return; + } + this.readMarker = this.readMarker || this.panel.readMarkerForEvent(ev.getId()!, ev === this.lastShownEvent); + if (!this.panel.showHiddenEvents && !shouldShow) { + // absorb hidden events to not split the summary + return; + } + this.events.push(wrappedEvent); + } + + private generateKey(): string { + return "eventlistsummary-" + this.events[0].event.getId(); + } + + public getTiles(): ReactNode[] { + // 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?.length) return []; + + const isGrouped = true; + const panel = this.panel; + const lastShownEvent = this.lastShownEvent; + const ret: ReactNode[] = []; + + if (panel.wantsDateSeparator(this.prevEvent, this.events[0].event.getDate())) { + const ts = this.events[0].event.getTs(); + ret.push( +
  • + +
  • , + ); + } + + // Ensure that the key of the EventListSummary does not change with new events in either direction. + // 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 keyEvent = this.events.find((e) => this.panel.grouperKeyMap.get(e.event)); + const key = + keyEvent && this.panel.grouperKeyMap.has(keyEvent.event) + ? this.panel.grouperKeyMap.get(keyEvent.event)! + : this.generateKey(); + if (!keyEvent) { + // Populate the weak map with the key. + // Note that we only set the key on the specific event it refers to, since this group might get + // split up in the future by other intervening events. If we were to set the key on all events + // currently in the group, we would risk later giving the same key to multiple groups. + this.panel.grouperKeyMap.set(this.events[0].event, key); + } + + let highlightInSummary = false; + let eventTiles: ReactNode[] | null = this.events + .map((e, i) => { + if (e.event.getId() === panel.props.highlightedEventId) { + highlightInSummary = true; + } + return panel.getTilesForEvent( + i === 0 ? this.prevEvent : this.events[i - 1].event, + e, + e.event === lastShownEvent, + isGrouped, + this.nextEvent, + this.nextEventTile, + ); + }) + .reduce((a, b) => a.concat(b), []); + + if (eventTiles.length === 0) { + eventTiles = null; + } + + // If a membership event is the start of visible history, tell the user + // why they can't see earlier messages + if (!this.panel.props.canBackPaginate && !this.prevEvent) { + ret.push(); + } + + ret.push( + e.event)} + onToggle={panel.onHeightChanged} // Update scroll state + startExpanded={highlightInSummary} + layout={this.panel.props.layout} + > + {eventTiles} + , + ); + + if (this.readMarker) { + ret.push(this.readMarker); + } + + return ret; + } + + public getNewPrevEvent(): MatrixEvent { + return this.events[this.events.length - 1].event; + } +}