From bcfcf7a7d21ecee2b35d5db18223ac976b836393 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh <hi@midhun.dev> Date: Tue, 20 May 2025 13:12:00 +0530 Subject: [PATCH] More WIP --- .../structures/MessagePanel-functional.tsx | 647 ++++++++++-------- .../structures/grouper/BaseGrouper.ts | 7 +- .../structures/grouper/CreationGrouper.tsx | 10 +- .../structures/grouper/MainGrouper.tsx | 16 +- 4 files changed, 386 insertions(+), 294 deletions(-) diff --git a/src/components/structures/MessagePanel-functional.tsx b/src/components/structures/MessagePanel-functional.tsx index d1a20d37a8..4f01bbb78e 100644 --- a/src/components/structures/MessagePanel-functional.tsx +++ b/src/components/structures/MessagePanel-functional.tsx @@ -164,18 +164,15 @@ export interface MessagePanelMethods { 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<MatrixEvent, string>; - shouldShowEvent: (mxEv: MatrixEvent, forceHideEvents: boolean) => boolean; - readMarkerForEvent: (eventId: string, isLastEvent: boolean) => ReactNode; - wantsSeparator: (prevEvent: MatrixEvent | null, mxEvent: MatrixEvent) => SeparatorKind; + scrollPanel: RefObject<ScrollPanel | null>; + getNodeForEventId: (eventId: string) => HTMLElement | undefined; + props: IProps; +} + +export interface GrouperPanel { getTilesForEvent: ( prevEvent: MatrixEvent | null, wrappedEvent: WrappedEvent, @@ -185,8 +182,18 @@ export interface MessagePanelMethods { nextEventWithTile?: MatrixEvent | null, ) => ReactNode[]; layout?: Layout; - scrollPanel: RefObject<ScrollPanel | null>; - getNodeForEventId: (eventId: string) => HTMLElement | undefined; + // A map to allow groupers to maintain consistent keys even if their first event is uprooted due to back-pagination. + grouperKeyMap: WeakMap<MatrixEvent, string>; + showHiddenEvents: boolean; + // Once dynamic content in the events load, make the scrollPanel check the scroll offsets. + onHeightChanged: () => void; + shouldShowEvent: (mxEv: MatrixEvent, forceHideEvents: boolean) => boolean; + readMarkerForEvent: (eventId: string, isLastEvent: boolean) => ReactNode; + wantsSeparator: (prevEvent: MatrixEvent | null, mxEvent: MatrixEvent) => SeparatorKind; + // ID of an event to highlight. If undefined, no event will be highlighted. + highlightedEventId?: string; + // whether the timeline can visually go back any further + canBackPaginate?: boolean; } interface IProps { @@ -270,7 +277,6 @@ interface IProps { callEventGroupers: Map<string, LegacyCallEventGrouper>; ref: RefObject<MessagePanelMethods | null>; - props: IProps; } interface IReadReceiptForUser { @@ -530,17 +536,40 @@ export const MessagePanelNew: React.FC<IProps> = (props: IProps) => { // 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)); + const resizeObserverRef = useRef<null | ResizeObserver>(null); + + const getResizeObserver = useCallback((): ResizeObserver => { + if (resizeObserverRef.current !== null) return resizeObserverRef.current; + const observer = new ResizeObserver(onHeightChanged); + resizeObserverRef.current = observer; + return observer; + }, [onHeightChanged]); useEffect(() => { unmounted.current = true; - const observer = resizeObserver.current; + const observer = getResizeObserver(); return () => { unmounted.current = false; readReceiptMap.current = {}; observer.disconnect(); }; - }, []); + }, [getResizeObserver]); + + useEffect(() => { + const room = props.room; + if (!room) return; + const pendingEditItem = getPendingEditItem(room, context.timelineRenderingType); + if (!props.editState && room && pendingEditItem) { + const event = room.findEventById(pendingEditItem); + defaultDispatcher.dispatch({ + action: Action.EditEvent, + event: !event?.isRedacted() ? event : null, + timelineRenderingType: context.timelineRenderingType, + }); + } + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.room]); const isUnmounting = (): boolean => unmounted.current; @@ -558,86 +587,103 @@ export const MessagePanelNew: React.FC<IProps> = (props: IProps) => { }; // 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; + const shouldShowEvent = useCallback( + (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 (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 (showHiddenEvents.current && !forceHideEvents) { + return true; + } - if (!haveRendererForEvent(mxEv, MatrixClientPeg.safeGet(), showHiddenEvents.current)) { - return false; // no tile = no show - } + if (!haveRendererForEvent(mxEv, MatrixClientPeg.safeGet(), showHiddenEvents.current)) { + return false; // no tile = no show + } - // Always show highlighted event - if (props.highlightedEventId === mxEv.getId()) return true; + // Always show highlighted event + if (props.highlightedEventId === mxEv.getId()) return true; - return !shouldHideEvent(mxEv, context); - }; + return !shouldHideEvent(mxEv, context); + }, + [context, props.events, props.hideThreadedMessages, props.highlightedEventId, props.room], + ); - 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 <li/> for it so that the - // algorithms which depend on its position on the screen aren't - // confused. - if (visible) { - hr = <hr style={{ opacity: 1, width: "99%" }} />; - } + const readMarkerForEvent = useCallback( + (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 <li/> for it so that the + // algorithms which depend on its position on the screen aren't + // confused. + if (visible) { + hr = <hr style={{ opacity: 1, width: "99%" }} />; + } - return ( - <li - key={"readMarker_" + eventId} - ref={readMarkerNode} - className="mx_MessagePanel_myReadMarker" - data-scroll-tokens={eventId} - > - {hr} - </li> - ); - } 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 = ( - <hr ref={collectGhostReadMarker} onTransitionEnd={onGhostTransitionEnd} data-eventid={eventId} /> - ); + return ( + <li + key={"readMarker_" + eventId} + ref={readMarkerNode} + className="mx_MessagePanel_myReadMarker" + data-scroll-tokens={eventId} + > + {hr} + </li> + ); + } 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 = ( + <hr ref={collectGhostReadMarker} onTransitionEnd={onGhostTransitionEnd} data-eventid={eventId} /> + ); - // 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 ( - <li key={"_readuptoghost_" + eventId} className="mx_MessagePanel_myReadMarker"> - {hr} - </li> - ); - } + // 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 ( + <li key={"_readuptoghost_" + eventId} className="mx_MessagePanel_myReadMarker"> + {hr} + </li> + ); + } - return null; - }; + return null; + }, + [ + collectGhostReadMarker, + context.timelineRenderingType, + ghostReadMarkers, + onGhostTransitionEnd, + props.readMarkerEventId, + props.readMarkerVisible, + ], + ); + + const collectEventTile = useCallback((eventId: string, node: UnwrappedEventTile): void => { + eventTiles.current[eventId] = node; + }, []); useImperativeHandle(props.ref, () => { return { @@ -685,11 +731,6 @@ export const MessagePanelNew: React.FC<IProps> = (props: IProps) => { }); } }, - showHiddenEvents: context?.showHiddenEvents ?? showHiddenEvents.current, - // TODO: Implement granular (per-room) hide options - shouldShowEvent, - readMarkerForEvent, - onHeightChanged, updateTimelineMinHeight: (): void => { const scrollPanelCurrent = scrollPanel.current; @@ -718,19 +759,206 @@ export const MessagePanelNew: React.FC<IProps> = (props: IProps) => { } 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, props, }; }); - const getEventTiles = (): ReactNode[] => { - if (!props.ref.current) return []; + const getTilesForEvent = useCallback( + ( + 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( + <li key={ts1}> + <DateSeparator key={ts1} roomId={props.room.roomId} ts={ts1} /> + </li>, + ); + } else if (wantsSeparator === SeparatorKind.LateEvent) { + const text = _t("timeline|late_event_separator", { + dateTime: formatDate(mxEv.getDate() ?? new Date()), + }); + ret.push( + <li key={ts1}> + <TimelineSeparator key={ts1} label={text}> + {text} + </TimelineSeparator> + </li>, + ); + } + } + + 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( + <EventTile + key={mxEv.getTxnId() || eventId} + as="li" + ref={(tile) => { + if (tile) collectEventTile(eventId, tile); + }} + alwaysShowTimestamps={props.alwaysShowTimestamps} + mxEvent={mxEv} + continuation={continuation} + isRedacted={mxEv.isRedacted()} + replacingEventId={mxEv.replacingEventId()} + editState={isEditing ? props.editState : undefined} + resizeObserver={getResizeObserver()} + 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; + }, + [ + collectEventTile, + context.timelineRenderingType, + getResizeObserver, + hideSender, + props.alwaysShowTimestamps, + props.callEventGroupers, + props.canBackPaginate, + props.editState, + props.getRelationsForEvent, + props.highlightedEventId, + props.isTwelveHour, + props.layout, + props.permalinkCreator, + props.room, + props.showReactions, + props.showReadReceipts, + props.showUrlPreview, + ], + ); + + // 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 = useCallback( + (events: WrappedEvent[]): Map<string, IReadReceiptProps[]> => { + const receiptsByEvent: Map<string, IReadReceiptProps[]> = new Map(); + const receiptsByUserId: Map<string, IReadReceiptForUser> = 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; + }, + [context.threadId, props.events, props.room, shouldShowEvent], + ); + + const getEventTiles = useCallback((): ReactNode[] => { + if (!grouperPanelRef.current) return []; // 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) @@ -810,9 +1038,9 @@ export const MessagePanelNew: React.FC<IProps> = (props: IProps) => { } for (const Grouper of groupers) { - if (Grouper.canStartGroup(props.ref.current, wrappedEvent) && !props.disableGrouping) { + if (Grouper.canStartGroup(grouperPanelRef.current, wrappedEvent) && !props.disableGrouping) { grouper = new Grouper( - props.ref.current, + grouperPanelRef.current, wrappedEvent, prevEvent, lastShownEvent, @@ -844,185 +1072,54 @@ export const MessagePanelNew: React.FC<IProps> = (props: IProps) => { } 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( - <li key={ts1}> - <DateSeparator key={ts1} roomId={props.room.roomId} ts={ts1} /> - </li>, - ); - } else if (wantsSeparator === SeparatorKind.LateEvent) { - const text = _t("timeline|late_event_separator", { - dateTime: formatDate(mxEv.getDate() ?? new Date()), - }); - ret.push( - <li key={ts1}> - <TimelineSeparator key={ts1} label={text}> - {text} - </TimelineSeparator> - </li>, - ); - } - } - - 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( - <EventTile - key={mxEv.getTxnId() || eventId} - as="li" - ref={(tile) => { - 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<string, IReadReceiptProps[]> => { - const receiptsByEvent: Map<string, IReadReceiptProps[]> = new Map(); - const receiptsByUserId: Map<string, IReadReceiptForUser> = 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 => { + }, [ + getReadReceiptsByShownEvent, + getTilesForEvent, + props.disableGrouping, + props.events, + props.showReadReceipts, + readMarkerForEvent, + shouldShowEvent, + ]); + + const grouperPanelRef = useRef<GrouperPanel | null>(null); + useImperativeHandle(grouperPanelRef, () => { + return { + grouperKeyMap: grouperKeyMapRef.current, + wantsSeparator: (prevEvent: MatrixEvent | null, mxEvent: MatrixEvent) => + getWantsSeparator(prevEvent, mxEvent, context.timelineRenderingType, props.canBackPaginate), + getTilesForEvent, + layout: props.layout, + canBackPaginate: props.canBackPaginate, + highlightedEventId: props.highlightedEventId, + showHiddenEvents: context?.showHiddenEvents ?? showHiddenEvents.current, + // TODO: Implement granular (per-room) hide options + shouldShowEvent, + readMarkerForEvent, + onHeightChanged, + }; + }, [ + context.showHiddenEvents, + context.timelineRenderingType, + getTilesForEvent, + onHeightChanged, + props.canBackPaginate, + props.highlightedEventId, + props.layout, + readMarkerForEvent, + shouldShowEvent, + ]); + + const onTypingShown = useCallback((): 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 onTypingHidden = useCallback((): void => { const scrollPanelCurrent = scrollPanel.current; if (scrollPanelCurrent) { // as hiding the typing notifications doesn't @@ -1033,7 +1130,7 @@ export const MessagePanelNew: React.FC<IProps> = (props: IProps) => { // reveal added padding to balance the notifs disappearing. scrollPanelCurrent.checkScroll(); } - }; + }, []); let topSpinner; let bottomSpinner; @@ -1084,7 +1181,11 @@ export const MessagePanelNew: React.FC<IProps> = (props: IProps) => { fixedChildren={ircResizer} > {topSpinner} - {getEventTiles()} + + { + // eslint-disable-next-line react-compiler/react-compiler + getEventTiles() + } {whoIsTypingDom} {bottomSpinner} </ScrollPanel> diff --git a/src/components/structures/grouper/BaseGrouper.ts b/src/components/structures/grouper/BaseGrouper.ts index 2a423eb8d0..1815a61861 100644 --- a/src/components/structures/grouper/BaseGrouper.ts +++ b/src/components/structures/grouper/BaseGrouper.ts @@ -10,8 +10,7 @@ import { type ReactNode } from "react"; import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { type WrappedEvent } from "../MessagePanel"; -import type MessagePanel from "../MessagePanel"; -import type { MessagePanelMethods } from "../MessagePanel-functional"; +import type { GrouperPanel } from "../MessagePanel-functional"; /* Grouper classes determine when events can be grouped together in a summary. * Groupers should have the following methods: @@ -25,7 +24,7 @@ import type { MessagePanelMethods } from "../MessagePanel-functional"; * when determining things such as whether a date separator is necessary */ export abstract class BaseGrouper { - public static canStartGroup = (_panel: MessagePanel | MessagePanelMethods, _ev: WrappedEvent): boolean => true; + public static canStartGroup = (_panel: GrouperPanel, _ev: WrappedEvent): boolean => true; public events: WrappedEvent[] = []; // events that we include in the group but then eject out and place above the group. @@ -33,7 +32,7 @@ export abstract class BaseGrouper { public readMarker: ReactNode; public constructor( - public readonly panel: MessagePanel | MessagePanelMethods, + public readonly panel: GrouperPanel, public readonly firstEventAndShouldShow: WrappedEvent, public readonly prevEvent: MatrixEvent | null, public readonly lastShownEvent: MatrixEvent | undefined, diff --git a/src/components/structures/grouper/CreationGrouper.tsx b/src/components/structures/grouper/CreationGrouper.tsx index 6263320c19..3b2bed0e43 100644 --- a/src/components/structures/grouper/CreationGrouper.tsx +++ b/src/components/structures/grouper/CreationGrouper.tsx @@ -12,24 +12,20 @@ import { KnownMembership } from "matrix-js-sdk/src/types"; import { BaseGrouper } from "./BaseGrouper"; import { type WrappedEvent } from "../MessagePanel"; -import type MessagePanel from "../MessagePanel"; 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"; import { SeparatorKind } from "../../views/messages/TimelineSeparator"; -import type { MessagePanelMethods } from "../MessagePanel-functional"; +import type { GrouperPanel } 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 | MessagePanelMethods, - { event }: WrappedEvent, - ): boolean { + public static canStartGroup = function (_panel: GrouperPanel, { event }: WrappedEvent): boolean { return event.getType() === EventType.RoomCreate; }; @@ -142,7 +138,7 @@ export class CreationGrouper extends BaseGrouper { onToggle={panel.onHeightChanged} // Update scroll state summaryMembers={ev.sender ? [ev.sender] : undefined} summaryText={summaryText} - layout={(this.panel as MessagePanelMethods).layout ?? (this.panel as MessagePanel).props.layout} + layout={this.panel.layout} > {eventTiles} </GenericEventListSummary>, diff --git a/src/components/structures/grouper/MainGrouper.tsx b/src/components/structures/grouper/MainGrouper.tsx index 9d9d69235d..b1dae64c88 100644 --- a/src/components/structures/grouper/MainGrouper.tsx +++ b/src/components/structures/grouper/MainGrouper.tsx @@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details. import React, { type ReactNode } from "react"; import { EventType, type 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"; @@ -18,7 +17,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"; +import type { GrouperPanel } from "../MessagePanel-functional"; const groupedStateEvents = [ EventType.RoomMember, @@ -29,10 +28,7 @@ const groupedStateEvents = [ // Wrap consecutive grouped events in a ListSummary export class MainGrouper extends BaseGrouper { - public static canStartGroup = function ( - panel: MessagePanel | MessagePanelMethods, - { event: ev, shouldShow }: WrappedEvent, - ): boolean { + public static canStartGroup = function (panel: GrouperPanel, { event: ev, shouldShow }: WrappedEvent): boolean { if (!shouldShow) return false; if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) { @@ -51,7 +47,7 @@ export class MainGrouper extends BaseGrouper { }; public constructor( - public readonly panel: MessagePanel, + public readonly panel: GrouperPanel, public readonly firstEventAndShouldShow: WrappedEvent, public readonly prevEvent: MatrixEvent | null, public readonly lastShownEvent: MatrixEvent | undefined, @@ -145,7 +141,7 @@ export class MainGrouper extends BaseGrouper { let highlightInSummary = false; let eventTiles: ReactNode[] | null = this.events .map((e, i) => { - if (e.event.getId() === panel.props.highlightedEventId) { + if (e.event.getId() === panel.highlightedEventId) { highlightInSummary = true; } return panel.getTilesForEvent( @@ -165,7 +161,7 @@ export class MainGrouper extends BaseGrouper { // 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) { + if (!this.panel.canBackPaginate && !this.prevEvent) { ret.push(<HistoryTile key="historytile" />); } @@ -176,7 +172,7 @@ export class MainGrouper extends BaseGrouper { events={this.events.map((e) => e.event)} onToggle={panel.onHeightChanged} // Update scroll state startExpanded={highlightInSummary} - layout={this.panel.props.layout} + layout={this.panel.layout} > {eventTiles} </EventListSummary>, -- GitLab