diff --git a/src/components/structures/MessagePanel-functional.tsx b/src/components/structures/MessagePanel-functional.tsx new file mode 100644 index 0000000000..7d92a45878 --- /dev/null +++ b/src/components/structures/MessagePanel-functional.tsx @@ -0,0 +1,1124 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2016-2023 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { + type JSX, + type ReactNode, + type TransitionEvent, + useContext, + useRef, + useState, + useEffect, + useCallback, + type RefObject, + useImperativeHandle, +} from "react"; +import classNames from "classnames"; +import { + type Room, + type MatrixClient, + RoomStateEvent, + EventStatus, + type 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 shouldHideEvent from "../../shouldHideEvent"; +import { formatDate, wantsDateSeparator } from "../../DateUtils"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import SettingsStore from "../../settings/SettingsStore"; +import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; +import { Layout } from "../../settings/enums/Layout"; +import EventTile, { + type GetRelationsForEvent, + type IReadReceiptProps, + isEligibleForSpecialReceipt, + type UnwrappedEventTile, +} from "../views/rooms/EventTile"; +import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import type LegacyCallEventGrouper from "./LegacyCallEventGrouper"; +import WhoIsTypingTile from "../views/rooms/WhoIsTypingTile"; +import ScrollPanel, { type IScrollState } from "./ScrollPanel"; +import DateSeparator from "../views/messages/DateSeparator"; +import TimelineSeparator, { SeparatorKind } from "../views/messages/TimelineSeparator"; +import ErrorBoundary from "../views/elements/ErrorBoundary"; +import type ResizeNotifier from "../../utils/ResizeNotifier"; +import Spinner from "../views/elements/Spinner"; +import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; +import type EditorStateTransfer from "../../utils/EditorStateTransfer"; +import { Action } from "../../dispatcher/actions"; +import { getEventDisplayInfo } from "../../utils/EventRenderingUtils"; +import { type IReadReceiptPosition } from "../views/rooms/ReadReceiptMarker"; +import { haveRendererForEvent } from "../../events/EventTileFactory"; +import { editorRoomKey } from "../../Editing"; +import { hasThreadSummary } from "../../utils/EventUtils"; +import { type BaseGrouper } from "./grouper/BaseGrouper"; +import { MainGrouper } from "./grouper/MainGrouper"; +import { CreationGrouper } from "./grouper/CreationGrouper"; +import { _t } from "../../languageHandler"; +import { getLateEventInfo } from "./grouper/LateEventGrouper"; +import { useSettingValue } from "../../hooks/useSettings"; +import { useRoomState } from "../../hooks/useRoomState"; +import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; + +const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes +const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; + +// 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 +export function shouldFormContinuation( + prevEvent: MatrixEvent | null, + mxEvent: MatrixEvent, + matrixClient: MatrixClient, + showHiddenEvents: boolean, + timelineRenderingType?: TimelineRenderingType, +): boolean { + if (timelineRenderingType === TimelineRenderingType.ThreadsList) return false; + // sanity check inputs + if (!prevEvent?.sender || !mxEvent.sender) return false; + // check if within the max continuation period + if (mxEvent.getTs() - prevEvent.getTs() > CONTINUATION_MAX_INTERVAL) return false; + + // As we summarise redactions, do not continue a redacted event onto a non-redacted one and vice-versa + if (mxEvent.isRedacted() !== prevEvent.isRedacted()) return false; + + // Some events should appear as continuations from previous events of different types. + if ( + mxEvent.getType() !== prevEvent.getType() && + (!continuedTypes.includes(mxEvent.getType() as EventType) || + !continuedTypes.includes(prevEvent.getType() as EventType)) + ) + return false; + + // Check if the sender is the same and hasn't changed their displayname/avatar between these events + if ( + mxEvent.sender.userId !== prevEvent.sender.userId || + mxEvent.sender.name !== prevEvent.sender.name || + mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl() + ) + return false; + + // Thread summaries in the main timeline should break up a continuation on both sides + if ( + (hasThreadSummary(mxEvent) || hasThreadSummary(prevEvent)) && + timelineRenderingType !== TimelineRenderingType.Thread + ) { + return false; + } + + // if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile + if (!haveRendererForEvent(prevEvent, matrixClient, showHiddenEvents)) return false; + + return true; +} + +export interface MessagePanelMethods { + /* return true if the content is fully scrolled down right now; else false. + */ + isAtBottom: () => boolean | undefined; + + /* get the current scroll state. See ScrollPanel.getScrollState for + * details. + * + * returns null if we are not mounted. + */ + getScrollState: () => IScrollState | null; + + // returns one of: + // + // null: there is no read marker + // -1: read marker is above the window + // 0: read marker is within the window + // +1: read marker is below the window + getReadMarkerPosition: () => number | null; + + /* jump to the bottom of the content. + */ + scrollToBottom: () => void; + + /** + * Scroll up/down in response to a scroll key + * + * @param {KeyboardEvent} ev: the keyboard event to handle + */ + handleScrollKey: (ev: React.KeyboardEvent | KeyboardEvent) => void; + + /* jump to the given event id. + * + * offsetBase gives the reference point for the pixelOffset. 0 means the + * top of the container, 1 means the bottom, and fractional values mean + * somewhere in the middle. If omitted, it defaults to 0. + * + * pixelOffset gives the number of pixels *above* the offsetBase that the + * node (specifically, the bottom of it) will be positioned. If omitted, it + * defaults to 0. + */ + scrollToEvent: (eventId: string, pixelOffset?: number, offsetBase?: number) => void; + + scrollToEventIfNeeded: (eventId: string) => void; + + showHiddenEvents: boolean; + + // Once dynamic content in the events load, make the scrollPanel check the scroll offsets. + onHeightChanged: () => void; + updateTimelineMinHeight: () => void; + onTimelineReset: () => void; + getTileForEventId: (eventId?: string) => UnwrappedEventTile | undefined; + // A map to allow groupers to maintain consistent keys even if their first event is uprooted due to back-pagination. + grouperKeyMap: WeakMap; + shouldShowEvent: (mxEv: MatrixEvent, forceHideEvents: boolean) => boolean; + readMarkerForEvent: (eventId: string, isLastEvent: boolean) => ReactNode; + wantsSeparator: (prevEvent: MatrixEvent | null, mxEvent: MatrixEvent) => SeparatorKind; + getTilesForEvent: ( + prevEvent: MatrixEvent | null, + wrappedEvent: WrappedEvent, + last?: boolean, + isGrouped?: boolean, + nextEvent?: WrappedEvent | null, + nextEventWithTile?: MatrixEvent | null, + ) => ReactNode[]; + layout?: Layout; + scrollPanel: RefObject; + getNodeForEventId: (eventId: string) => HTMLElement | undefined; +} + +interface IProps { + // the list of MatrixEvents to display + events: MatrixEvent[]; + + // true to give the component a 'display: none' style. + hidden?: boolean; + + // true to show a spinner at the top of the timeline to indicate + // back-pagination in progress + backPaginating?: boolean; + + // true to show a spinner at the end of the timeline to indicate + // forward-pagination in progress + forwardPaginating?: boolean; + + // ID of an event to highlight. If undefined, no event will be highlighted. + highlightedEventId?: string; + + // The room these events are all in together, if any. + // (The notification panel won't have a room here, for example.) + room?: Room; + + // Should we show URL Previews + showUrlPreview?: boolean; + + // event after which we should show a read marker + readMarkerEventId?: string | null; + + // whether the read marker should be visible + readMarkerVisible?: boolean; + + // the userid of our user. This is used to suppress the read marker + // for pending messages. + ourUserId?: string; + + // whether the timeline can visually go back any further + canBackPaginate?: boolean; + + // whether to show read receipts + showReadReceipts?: boolean; + + // true if updates to the event list should cause the scroll panel to + // scroll down when we are at the bottom of the window. See ScrollPanel + // for more details. + stickyBottom?: boolean; + + // className for the panel + className?: string; + + // show twelve hour timestamps + isTwelveHour?: boolean; + + // show timestamps always + alwaysShowTimestamps?: boolean; + + // whether to show reactions for an event + showReactions?: boolean; + + // which layout to use + layout?: Layout; + + resizeNotifier?: ResizeNotifier; + permalinkCreator?: RoomPermalinkCreator; + editState?: EditorStateTransfer; + + // callback which is called when the panel is scrolled. + onScroll?(event: Event): void; + + // callback which is called when more content is needed. + onFillRequest?(backwards: boolean): Promise; + + // helper function to access relations for an event + onUnfillRequest?(backwards: boolean, scrollToken: string | null): void; + + getRelationsForEvent?: GetRelationsForEvent; + + hideThreadedMessages?: boolean; + disableGrouping?: boolean; + + callEventGroupers: Map; + ref: RefObject; +} + +interface IReadReceiptForUser { + lastShownEventId: string; + receipt: IReadReceiptProps; +} + +const useGhostReadMarkers = ( + readMarkerEventId: string | undefined | null, + readMarkerVisible: boolean | undefined, +): { + ghostReadMarkers: string[]; + collectGhostReadMarker: (node: HTMLElement | null) => void; + onGhostTransitionEnd: (ev: TransitionEvent) => void; +} => { + const [ghostReadMarkers, setGhostReadMarkers] = useState([]); + + const previousReadMarker = useRef<{ + readMarkerEventId: string | undefined | null; + readMarkerVisible: boolean | undefined; + }>({ + readMarkerEventId, + readMarkerVisible, + }); + + useEffect(() => { + if ( + previousReadMarker.current.readMarkerVisible && + previousReadMarker.current.readMarkerEventId && + readMarkerEventId !== previousReadMarker.current.readMarkerEventId + ) { + setGhostReadMarkers((markers) => [...markers, previousReadMarker.current.readMarkerEventId!]); + previousReadMarker.current = { + readMarkerEventId, + readMarkerVisible, + }; + } + }, [readMarkerEventId, readMarkerVisible]); + + const collectGhostReadMarker = useCallback((node: HTMLElement | null): void => { + if (node) { + // now the element has appeared, change the style which will trigger the CSS transition + requestAnimationFrame(() => { + node.style.width = "10%"; + node.style.opacity = "0"; + }); + } + }, []); + + const onGhostTransitionEnd = useCallback((ev: TransitionEvent): void => { + // we can now clean up the ghost element + const finishedEventId = (ev.target as HTMLElement).dataset.eventid; + setGhostReadMarkers((markers) => markers.filter((eid) => eid !== finishedEventId)); + }, []); + + return { ghostReadMarkers, collectGhostReadMarker, onGhostTransitionEnd }; +}; + +/** + * Find the next event in the list, and the next visible event in the list. + * + * @param events - the list of events to look in and whether they are shown + * @param i - where in the list we are now + * + * @returns { nextEvent, nextTile } + * + * nextEvent is the event after i in the supplied array. + * + * nextTile is the first event in the array after i that we will show a tile + * for. It is used to to determine the 'last successful' flag when rendering + * the tile. + */ +function getNextEventInfo( + events: WrappedEvent[], + i: number, +): { nextEventAndShouldShow: WrappedEvent | null; nextTile: MatrixEvent | null } { + // WARNING: this method is on a hot path. + + const nextEventAndShouldShow = i < events.length - 1 ? events[i + 1] : null; + + const nextTile = findFirstShownAfter(i, events); + + return { nextEventAndShouldShow, nextTile }; +} + +function getPendingEditItem(room: Room, timelineRenderingType: TimelineRenderingType): string | null { + if (!room) { + return null; + } + + try { + return localStorage.getItem(editorRoomKey(room.roomId, timelineRenderingType)); + } catch (err) { + logger.error(err); + return null; + } +} + +function isSentState(ev: MatrixEvent): boolean { + const status = ev.getAssociatedStatus(); + // A falsey state applies to events which have come down sync, including remote echoes + return !status || status === EventStatus.SENT; +} + +function getWantsSeparator( + prevEvent: MatrixEvent | null, + mxEvent: MatrixEvent, + timelineRenderingType: TimelineRenderingType, + canBackPaginate?: boolean, +): SeparatorKind { + if (timelineRenderingType === TimelineRenderingType.ThreadsList) { + return SeparatorKind.None; + } + + if (prevEvent !== null) { + // If the previous event was late but current is not then show a date separator for orientation + // Otherwise if the current event is of a different late group than the previous show a late event separator + const lateEventInfo = getLateEventInfo(mxEvent); + if (lateEventInfo?.group_id !== getLateEventInfo(prevEvent)?.group_id) { + return lateEventInfo !== undefined ? SeparatorKind.LateEvent : SeparatorKind.Date; + } + } + + // first event in the panel: depends on if we could back-paginate from here. + if (prevEvent === null && !canBackPaginate) { + return SeparatorKind.Date; + } + + const nextEventDate = mxEvent.getDate() ?? new Date(); + if (prevEvent !== null && wantsDateSeparator(prevEvent.getDate() || undefined, nextEventDate)) { + return SeparatorKind.Date; + } + + return SeparatorKind.None; +} + +// Get a list of read receipts that should be shown next to this event +// Receipts are objects which have a 'userId', 'roomMember' and 'ts'. +function getReadReceiptsForEvent( + room: Room | undefined, + event: MatrixEvent, + threadId?: string, +): IReadReceiptProps[] | null { + const myUserId = MatrixClientPeg.safeGet().credentials.userId; + + // get list of read receipts, sorted most recent first + if (!room) { + return null; + } + + const receiptDestination = threadId ? room.getThread(threadId) : room; + + const receipts: IReadReceiptProps[] = []; + + if (!receiptDestination) { + logger.debug("Discarding request, could not find the receiptDestination for event: " + threadId); + return receipts; + } + + receiptDestination.getReceiptsForEvent(event).forEach((r) => { + if (!r.userId || !isSupportedReceiptType(r.type) || r.userId === myUserId) { + return; // ignore non-read receipts and receipts from self. + } + if (MatrixClientPeg.safeGet().isUserIgnored(r.userId)) { + return; // ignore ignored users + } + const member = room.getMember(r.userId); + receipts.push({ + userId: r.userId, + roomMember: member, + ts: r.data ? r.data.ts : 0, + }); + }); + return receipts; +} + +/* (almost) stateless UI component which builds the event tiles in the room timeline. + */ +export const MessagePanelNew: React.FC = (props: IProps) => { + const context = useContext(RoomContext); + + // opaque readreceipt info for each userId; used by ReadReceiptMarker + // to manage its animations + const readReceiptMap = useRef<{ [userId: string]: IReadReceiptPosition }>({}); + + // Track read receipts by event ID. For each _shown_ event ID, we store + // the list of read receipts to display: + // [ + // { + // userId: string, + // member: RoomMember, + // ts: number, + // }, + // ] + // This is recomputed on each render. It's only stored on the component + // for ease of passing the data around since it's computed in one pass + // over all events. + const readReceiptsByEvent = useRef>(new Map()); + + // Track read receipts by user ID. For each user ID we've ever shown a + // a read receipt for, we store an object: + // { + // lastShownEventId: string, + // receipt: { + // userId: string, + // member: RoomMember, + // ts: number, + // }, + // } + // so that we can always keep receipts displayed by reverting back to + // the last shown event for that user ID when needed. This may feel like + // it duplicates the receipt storage in the room, but at this layer, we + // are tracking _shown_ event IDs, which the JS SDK knows nothing about. + // This is recomputed on each render, using the data from the previous + // render as our fallback for any user IDs we can't match a receipt to a + // displayed event in the current render cycle. + const readReceiptsByUserId = useRef>(new Map()); + + // Cache these settings on mount since Settings is expensive to query, + // and we check this in a hot code path. This is also cached in our + // RoomContext, however we still need a fallback for roomless MessagePanels. + const showHiddenEvents = useRef(SettingsStore.getValue("showHiddenEventsInTimeline")); + + const readMarkerNode = useRef(null); + const whoIsTyping = useRef(null); + const scrollPanel = useRef(null); + + const eventTiles = useRef>({}); + + // A map to allow groupers to maintain consistent keys even if their first event is uprooted due to back-pagination. + const grouperKeyMapRef = useRef(new WeakMap()); + + const { ghostReadMarkers, collectGhostReadMarker, onGhostTransitionEnd } = useGhostReadMarkers( + props.readMarkerEventId, + props.readMarkerVisible, + ); + + const showTypingNotifications = useSettingValue("showTypingNotifications"); + + const shouldHideSender = useCallback((): boolean => { + return !!props.room && props.room.getInvitedAndJoinedMemberCount() <= 2 && props.layout === Layout.Bubble; + }, [props.room, props.layout]); + + const updateHideSender = useCallback((): void => { + const newHideSender = shouldHideSender(); + setHideSender(newHideSender); + }, [shouldHideSender]); + + // todo: passing this callback is correct? + const [hideSender, setHideSender] = useState(shouldHideSender); + + useTypedEventEmitter(props.room, RoomStateEvent.Update, () => { + updateHideSender(); + }); + + const unmounted = useRef(false); + + // Once dynamic content in the events load, make the scrollPanel check the scroll offsets. + const onHeightChanged = useCallback((): void => scrollPanel.current?.checkScroll(), []); + const resizeObserver = useRef(new ResizeObserver(onHeightChanged)); + + useEffect(() => { + unmounted.current = true; + const observer = resizeObserver.current; + return () => { + unmounted.current = false; + readReceiptMap.current = {}; + observer.disconnect(); + }; + }, []); + + const isUnmounting = (): boolean => unmounted.current; + + useEffect(() => { + updateHideSender(); + }, [props.layout, updateHideSender]); + + /* get the DOM node representing the given event */ + const getNodeForEventId = (eventId: string): HTMLElement | undefined => { + if (!eventTiles.current) { + return undefined; + } + + return eventTiles.current[eventId]?.ref?.current ?? undefined; + }; + + // TODO: Implement granular (per-room) hide options + const shouldShowEvent = (mxEv: MatrixEvent, forceHideEvents = false): boolean => { + if (props.hideThreadedMessages && props.room) { + const { shouldLiveInRoom } = props.room.eventShouldLiveIn(mxEv, props.events); + if (!shouldLiveInRoom) { + return false; + } + } + + if (MatrixClientPeg.safeGet().isUserIgnored(mxEv.getSender()!)) { + return false; // ignored = no show (only happens if the ignore happens after an event was received) + } + + if (showHiddenEvents && !forceHideEvents) { + return true; + } + + if (!haveRendererForEvent(mxEv, MatrixClientPeg.safeGet(), showHiddenEvents.current)) { + return false; // no tile = no show + } + + // Always show highlighted event + if (props.highlightedEventId === mxEv.getId()) return true; + + return !shouldHideEvent(mxEv, context); + }; + + const readMarkerForEvent = (eventId: string, isLastEvent: boolean): JSX.Element | null => { + if (context.timelineRenderingType === TimelineRenderingType.File) return null; + + const visible = !isLastEvent && props.readMarkerVisible; + + if (props.readMarkerEventId === eventId) { + let hr; + // if the read marker comes at the end of the timeline (except + // for local echoes, which are excluded from RMs, because they + // don't have useful event ids), we don't want to show it, but + // we still want to create the
  • for it so that the + // algorithms which depend on its position on the screen aren't + // confused. + if (visible) { + hr =
    ; + } + + return ( +
  • + {hr} +
  • + ); + } else if (ghostReadMarkers.includes(eventId)) { + // We render 'ghost' read markers in the DOM while they + // transition away. This allows the actual read marker + // to be in the right place straight away without having + // to wait for the transition to finish. + // There are probably much simpler ways to do this transition, + // possibly using react-transition-group which handles keeping + // elements in the DOM whilst they transition out, although our + // case is a little more complex because only some of the items + // transition (ie. the read markers do but the event tiles do not) + // and TransitionGroup requires that all its children are Transitions. + const hr = ( +
    + ); + + // give it a key which depends on the event id. That will ensure that + // we get a new DOM node (restarting the animation) when the ghost + // moves to a different event. + return ( +
  • + {hr} +
  • + ); + } + + return null; + }; + + useImperativeHandle(props.ref, () => { + return { + isAtBottom: () => scrollPanel.current?.isAtBottom(), + getScrollState: () => scrollPanel.current?.getScrollState() ?? null, + getReadMarkerPosition: (): number | null => { + const readMarker = readMarkerNode.current; + const messageWrapper = scrollPanel.current?.divScroll; + + if (!readMarker || !messageWrapper) { + return null; + } + + const wrapperRect = messageWrapper.getBoundingClientRect(); + const readMarkerRect = readMarker.getBoundingClientRect(); + + // the read-marker pretends to have zero height when it is actually + // two pixels high; +2 here to account for that. + if (readMarkerRect.bottom + 2 < wrapperRect.top) { + return -1; + } else if (readMarkerRect.top < wrapperRect.bottom) { + return 0; + } else { + return 1; + } + }, + scrollToTop: () => { + scrollPanel.current?.scrollToTop(); + }, + scrollToBottom: () => { + scrollPanel.current?.scrollToBottom(); + }, + handleScrollKey: (ev: React.KeyboardEvent | KeyboardEvent) => { + scrollPanel.current?.handleScrollKey(ev); + }, + scrollToEvent: (eventId: string, pixelOffset?: number, offsetBase?: number) => { + scrollPanel.current?.scrollToToken(eventId, pixelOffset, offsetBase); + }, + scrollToEventIfNeeded: (eventId: string) => { + const node = getNodeForEventId(eventId); + if (node) { + node.scrollIntoView({ + block: "nearest", + behavior: "instant", + }); + } + }, + showHiddenEvents: context?.showHiddenEvents ?? showHiddenEvents.current, + // TODO: Implement granular (per-room) hide options + shouldShowEvent, + readMarkerForEvent, + onHeightChanged, + updateTimelineMinHeight: (): void => { + const scrollPanelCurrent = scrollPanel.current; + + if (scrollPanelCurrent) { + const isAtBottom = scrollPanelCurrent.isAtBottom(); + const whoIsTypingCurrent = whoIsTyping.current; + const isTypingVisible = whoIsTypingCurrent && whoIsTypingCurrent.isVisible(); + // when messages get added to the timeline, + // but somebody else is still typing, + // update the min-height, so once the last + // person stops typing, no jumping occurs + if (isAtBottom && isTypingVisible) { + scrollPanelCurrent.preventShrinking(); + } + } + }, + onTimelineReset: (): void => { + const scrollPanelCurrent = scrollPanel.current; + if (scrollPanelCurrent) { + scrollPanelCurrent.clearPreventShrinking(); + } + }, + getTileForEventId: (eventId?: string): UnwrappedEventTile | undefined => { + if (!eventTiles.current || !eventId) { + return undefined; + } + return eventTiles.current[eventId]; + }, + grouperKeyMap: grouperKeyMapRef.current, + wantsSeparator: (prevEvent: MatrixEvent | null, mxEvent: MatrixEvent) => + getWantsSeparator(prevEvent, mxEvent, context.timelineRenderingType, props.canBackPaginate), + getTilesForEvent, + layout: props.layout, + scrollPanel, + getNodeForEventId, + }; + }); + + const getEventTiles = (): ReactNode[] => { + // first figure out which is the last event in the list which we're + // actually going to show; this allows us to behave slightly + // differently for the last event in the list. (eg show timestamp) + // + // we also need to figure out which is the last event we show which isn't + // a local echo, to manage the read-marker. + let lastShownEvent: MatrixEvent | undefined; + const events: WrappedEvent[] = props.events.map((event) => { + return { event, shouldShow: shouldShowEvent(event) }; + }); + + const userId = MatrixClientPeg.safeGet().getSafeUserId(); + + let foundLastSuccessfulEvent = false; + let lastShownNonLocalEchoIndex = -1; + // Find the indices of the last successful event we sent and the last non-local-echo events shown + for (let i = events.length - 1; i >= 0; i--) { + const { event, shouldShow } = events[i]; + if (!shouldShow) { + continue; + } + + if (lastShownEvent === undefined) { + lastShownEvent = event; + } + + if (!foundLastSuccessfulEvent && isSentState(event) && isEligibleForSpecialReceipt(event)) { + foundLastSuccessfulEvent = true; + // If we are not sender of this last successful event eligible for special receipt then we stop here + // As we do not want to render our sent receipt if there are more receipts below it and events sent + // by other users get a synthetic read receipt for their sent events. + if (event.getSender() === userId) { + events[i].lastSuccessfulWeSent = true; + } + } + + if (lastShownNonLocalEchoIndex < 0 && !event.status) { + lastShownNonLocalEchoIndex = i; + } + + if (lastShownNonLocalEchoIndex >= 0 && foundLastSuccessfulEvent) { + break; + } + } + + const ret: ReactNode[] = []; + let prevEvent: MatrixEvent | null = null; // the last event we showed + + // Note: the EventTile might still render a "sent/sending receipt" independent of + // this information. When not providing read receipt information, the tile is likely + // to assume that sent receipts are to be shown more often. + readReceiptsByEvent.current = new Map(); + if (props.showReadReceipts) { + readReceiptsByEvent.current = getReadReceiptsByShownEvent(events); + } + + let grouper: BaseGrouper | null = null; + + for (let i = 0; i < events.length; i++) { + const wrappedEvent = events[i]; + const { event, shouldShow } = wrappedEvent; + const eventId = event.getId()!; + const last = event === lastShownEvent; + const { nextEventAndShouldShow, nextTile } = getNextEventInfo(events, i); + + if (grouper) { + if (grouper.shouldGroup(wrappedEvent)) { + grouper.add(wrappedEvent); + continue; + } else { + // not part of group, so get the group tiles, close the + // group, and continue like a normal event + ret.push(...grouper.getTiles()); + prevEvent = grouper.getNewPrevEvent(); + grouper = null; + } + } + + for (const Grouper of groupers) { + if (Grouper.canStartGroup(props.ref.current, wrappedEvent) && !props.disableGrouping) { + grouper = new Grouper( + props.ref.current, + wrappedEvent, + prevEvent, + lastShownEvent, + nextEventAndShouldShow, + nextTile, + ); + break; // break on first grouper + } + } + + if (!grouper) { + if (shouldShow) { + // make sure we unpack the array returned by getTilesForEvent, + // otherwise React will auto-generate keys, and we will end up + // replacing all the DOM elements every time we paginate. + ret.push( + ...getTilesForEvent(prevEvent, wrappedEvent, last, false, nextEventAndShouldShow, nextTile), + ); + prevEvent = event; + } + + const readMarker = readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex); + if (readMarker) ret.push(readMarker); + } + } + + if (grouper) { + ret.push(...grouper.getTiles()); + } + + return ret; + }; + + const getTilesForEvent = ( + prevEvent: MatrixEvent | null, + wrappedEvent: WrappedEvent, + last = false, + isGrouped = false, + nextEvent: WrappedEvent | null = null, + nextEventWithTile: MatrixEvent | null = null, + ): ReactNode[] => { + const mxEv = wrappedEvent.event; + const ret: ReactNode[] = []; + + const isEditing = props.editState?.getEvent().getId() === mxEv.getId(); + // local echoes have a fake date, which could even be yesterday. Treat them as 'today' for the date separators. + const ts1 = mxEv.getTs() ?? Date.now(); + + // do we need a separator since the last event? + const wantsSeparator = getWantsSeparator(prevEvent, mxEv, context.timelineRenderingType, props.canBackPaginate); + if (!isGrouped && props.room) { + if (wantsSeparator === SeparatorKind.Date) { + ret.push( +
  • + +
  • , + ); + } else if (wantsSeparator === SeparatorKind.LateEvent) { + const text = _t("timeline|late_event_separator", { + dateTime: formatDate(mxEv.getDate() ?? new Date()), + }); + ret.push( +
  • + + {text} + +
  • , + ); + } + } + + const cli = MatrixClientPeg.safeGet(); + let lastInSection = true; + if (nextEventWithTile) { + const nextEv = nextEventWithTile; + const willWantSeparator = getWantsSeparator( + mxEv, + nextEv, + context.timelineRenderingType, + props.canBackPaginate, + ); + lastInSection = + willWantSeparator === SeparatorKind.Date || + mxEv.getSender() !== nextEv.getSender() || + getEventDisplayInfo(cli, nextEv, showHiddenEvents.current).isInfoMessage || + !shouldFormContinuation(mxEv, nextEv, cli, showHiddenEvents.current, context.timelineRenderingType); + } + + // is this a continuation of the previous message? + const continuation = + wantsSeparator === SeparatorKind.None && + shouldFormContinuation(prevEvent, mxEv, cli, showHiddenEvents.current, context.timelineRenderingType); + + const eventId = mxEv.getId()!; + const highlight = eventId === props.highlightedEventId; + + const readReceipts = readReceiptsByEvent.current.get(eventId); + + const callEventGrouper = props.callEventGroupers.get(mxEv.getContent().call_id); + // use txnId as key if available so that we don't remount during sending + ret.push( + { + if (tile) collectEventTile(eventId, tile); + }} + alwaysShowTimestamps={props.alwaysShowTimestamps} + mxEvent={mxEv} + continuation={continuation} + isRedacted={mxEv.isRedacted()} + replacingEventId={mxEv.replacingEventId()} + editState={isEditing ? props.editState : undefined} + resizeObserver={resizeObserver.current} + readReceipts={readReceipts} + readReceiptMap={readReceiptMap.current} + showUrlPreview={props.showUrlPreview} + checkUnmounting={isUnmounting} + eventSendStatus={mxEv.getAssociatedStatus() ?? undefined} + isTwelveHour={props.isTwelveHour} + permalinkCreator={props.permalinkCreator} + last={last} + lastInSection={lastInSection} + lastSuccessful={wrappedEvent.lastSuccessfulWeSent} + isSelectedEvent={highlight} + getRelationsForEvent={props.getRelationsForEvent} + showReactions={props.showReactions} + layout={props.layout} + showReadReceipts={props.showReadReceipts} + callEventGrouper={callEventGrouper} + hideSender={hideSender} + />, + ); + + return ret; + }; + + // Get an object that maps from event ID to a list of read receipts that + // should be shown next to that event. If a hidden event has read receipts, + // they are folded into the receipts of the last shown event. + const getReadReceiptsByShownEvent = (events: WrappedEvent[]): Map => { + const receiptsByEvent: Map = new Map(); + const receiptsByUserId: Map = new Map(); + + let lastShownEventId: string | undefined; + for (const event of props.events) { + if (shouldShowEvent(event)) { + lastShownEventId = event.getId(); + } + if (!lastShownEventId) { + continue; + } + + const existingReceipts = receiptsByEvent.get(lastShownEventId) || []; + const newReceipts = getReadReceiptsForEvent(props.room, event, context.threadId); + if (!newReceipts) continue; + receiptsByEvent.set(lastShownEventId, existingReceipts.concat(newReceipts)); + + // Record these receipts along with their last shown event ID for + // each associated user ID. + for (const receipt of newReceipts) { + receiptsByUserId.set(receipt.userId, { + lastShownEventId, + receipt, + }); + } + } + + // It's possible in some cases (for example, when a read receipt + // advances before we have paginated in the new event that it's marking + // received) that we can temporarily not have a matching event for + // someone which had one in the last. By looking through our previous + // mapping of receipts by user ID, we can cover recover any receipts + // that would have been lost by using the same event ID from last time. + for (const userId of readReceiptsByUserId.current.keys()) { + if (receiptsByUserId.get(userId)) { + continue; + } + const { lastShownEventId, receipt } = readReceiptsByUserId.current.get(userId)!; + const existingReceipts = receiptsByEvent.get(lastShownEventId) || []; + receiptsByEvent.set(lastShownEventId, existingReceipts.concat(receipt)); + receiptsByUserId.set(userId, { lastShownEventId, receipt }); + } + readReceiptsByUserId.current = receiptsByUserId; + + // After grouping receipts by shown events, do another pass to sort each + // receipt list. + for (const receipts of receiptsByEvent.values()) { + receipts.sort((r1, r2) => { + return r2.ts - r1.ts; + }); + } + + return receiptsByEvent; + }; + + const collectEventTile = (eventId: string, node: UnwrappedEventTile): void => { + eventTiles.current[eventId] = node; + }; + + const onTypingShown = (): void => { + const scrollPanelCurrent = scrollPanel.current; + // this will make the timeline grow, so checkScroll + scrollPanelCurrent?.checkScroll(); + if (scrollPanelCurrent && scrollPanelCurrent.getScrollState().stuckAtBottom) { + scrollPanelCurrent.preventShrinking(); + } + }; + + const onTypingHidden = (): void => { + const scrollPanelCurrent = scrollPanel.current; + if (scrollPanelCurrent) { + // as hiding the typing notifications doesn't + // update the scrollPanel, we tell it to apply + // the shrinking prevention once the typing notifs are hidden + scrollPanelCurrent.updatePreventShrinking(); + // order is important here as checkScroll will scroll down to + // reveal added padding to balance the notifs disappearing. + scrollPanelCurrent.checkScroll(); + } + }; + + let topSpinner; + let bottomSpinner; + if (props.backPaginating) { + topSpinner = ( +
  • + +
  • + ); + } + if (props.forwardPaginating) { + bottomSpinner = ( +
  • + +
  • + ); + } + + const style = props.hidden ? { display: "none" } : {}; + + let whoIsTypingDom; + if (props.room && showTypingNotifications && context.timelineRenderingType === TimelineRenderingType.Room) { + whoIsTypingDom = ( + + ); + } + + let ircResizer: JSX.Element | undefined; + if (props.layout == Layout.IRC) { + ircResizer = ; + } + + const classes = classNames(props.className, { + mx_MessagePanel_narrow: context.narrow, + }); + + return ( + + + {topSpinner} + {getEventTiles()} + {whoIsTypingDom} + {bottomSpinner} + + + ); +}; + +/** + * Holds on to an event, caching the information about it in the context of the current messages list. + * Avoids calling shouldShowEvent more times than we need to. + * 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. + */ +export interface WrappedEvent { + event: MatrixEvent; + shouldShow?: boolean; + lastSuccessfulWeSent?: boolean; +} + +// all the grouper classes that we use, ordered by priority +const groupers = [CreationGrouper, MainGrouper]; + +/** + * Look through the supplied list of WrappedEvent, and return the first + * event that is >start items through the list, and is shown. + */ +function findFirstShownAfter(start: number, events: WrappedEvent[]): MatrixEvent | null { + // Note: this could be done with something like: + // events.slice(i + 1).find((e) => e.shouldShow)?.event ?? null; + // but it is ~10% slower, and this is on the critical path. + + for (let n = start + 1; n < events.length; n++) { + const { event, shouldShow } = events[n]; + if (shouldShow) { + return event; + } + } + return null; +} diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index a523754c41..d7be2d6e1d 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -44,7 +44,7 @@ import dis from "../../dispatcher/dispatcher"; import { Action } from "../../dispatcher/actions"; import Timer from "../../utils/Timer"; import shouldHideEvent from "../../shouldHideEvent"; -import MessagePanel from "./MessagePanel"; +// import MessagePanel from "./MessagePanel"; import { type IScrollState } from "./ScrollPanel"; import { type ActionPayload } from "../../dispatcher/payloads"; import type ResizeNotifier from "../../utils/ResizeNotifier"; @@ -58,6 +58,7 @@ import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload" import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { haveRendererForEvent } from "../../events/EventTileFactory"; +import { type MessagePanelMethods, MessagePanelNew } from "./MessagePanel-functional"; // These pagination sizes are higher than they may possibly need be // once https://github.com/matrix-org/matrix-spec-proposals/pull/3874 lands @@ -235,7 +236,7 @@ class TimelinePanel extends React.Component { private lastRRSentEventId: string | null | undefined = undefined; private lastRMSentEventId: string | null | undefined = undefined; - private readonly messagePanel = createRef(); + private readonly messagePanel = createRef(); private dispatcherRef?: string; private timelineWindow?: TimelineWindow; private unmounted = false; @@ -1821,7 +1822,7 @@ class TimelinePanel extends React.Component { this.state.forwardPaginating || ["PREPARED", "CATCHUP"].includes(this.state.clientSyncState!); const events = this.state.events; return ( - true; + public static canStartGroup = (_panel: MessagePanel | MessagePanelMethods, _ev: WrappedEvent): boolean => true; public events: WrappedEvent[] = []; // events that we include in the group but then eject out and place above the group. @@ -32,14 +33,14 @@ export abstract class BaseGrouper { public readMarker: ReactNode; public constructor( - public readonly panel: MessagePanel, + public readonly panel: MessagePanel | MessagePanelMethods, 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( + this.readMarker = panel?.readMarkerForEvent( firstEventAndShouldShow.event.getId()!, firstEventAndShouldShow.event === lastShownEvent, ); diff --git a/src/components/structures/grouper/CreationGrouper.tsx b/src/components/structures/grouper/CreationGrouper.tsx index 009f5bdc26..6263320c19 100644 --- a/src/components/structures/grouper/CreationGrouper.tsx +++ b/src/components/structures/grouper/CreationGrouper.tsx @@ -19,18 +19,23 @@ import DateSeparator from "../../views/messages/DateSeparator"; import NewRoomIntro from "../../views/rooms/NewRoomIntro"; import GenericEventListSummary from "../../views/elements/GenericEventListSummary"; import { SeparatorKind } from "../../views/messages/TimelineSeparator"; +import type { MessagePanelMethods } from "../MessagePanel-functional"; // 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 { + public static canStartGroup = function ( + _panel: MessagePanel | MessagePanelMethods, + { event }: WrappedEvent, + ): boolean { return event.getType() === EventType.RoomCreate; }; public shouldGroup({ event, shouldShow }: WrappedEvent): boolean { const panel = this.panel; + if (!panel) return false; const createEvent = this.firstEventAndShouldShow.event; if (!shouldShow) { return true; @@ -137,7 +142,7 @@ export class CreationGrouper extends BaseGrouper { onToggle={panel.onHeightChanged} // Update scroll state summaryMembers={ev.sender ? [ev.sender] : undefined} summaryText={summaryText} - layout={this.panel.props.layout} + layout={(this.panel as MessagePanelMethods).layout ?? (this.panel as MessagePanel).props.layout} > {eventTiles} , diff --git a/src/components/structures/grouper/MainGrouper.tsx b/src/components/structures/grouper/MainGrouper.tsx index e686f1aa81..9d9d69235d 100644 --- a/src/components/structures/grouper/MainGrouper.tsx +++ b/src/components/structures/grouper/MainGrouper.tsx @@ -18,6 +18,7 @@ import DateSeparator from "../../views/messages/DateSeparator"; import HistoryTile from "../../views/rooms/HistoryTile"; import EventListSummary from "../../views/elements/EventListSummary"; import { SeparatorKind } from "../../views/messages/TimelineSeparator"; +import type { MessagePanelMethods } from "../MessagePanel-functional"; const groupedStateEvents = [ EventType.RoomMember, @@ -28,7 +29,10 @@ const groupedStateEvents = [ // Wrap consecutive grouped events in a ListSummary export class MainGrouper extends BaseGrouper { - public static canStartGroup = function (panel: MessagePanel, { event: ev, shouldShow }: WrappedEvent): boolean { + public static canStartGroup = function ( + panel: MessagePanel | MessagePanelMethods, + { event: ev, shouldShow }: WrappedEvent, + ): boolean { if (!shouldShow) return false; if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) {