diff --git a/package.json b/package.json index 52117250fdf..e3c9d40bc11 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "react-string-replace": "^1.1.1", "react-transition-group": "^4.4.1", "react-virtualized": "^9.22.5", + "react-virtuoso": "^4.12.7", "rfc4648": "^1.4.0", "sanitize-filename": "^1.6.3", "sanitize-html": "2.16.0", diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 2441182bc83..844e8824f95 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -35,7 +35,8 @@ import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResiz import defaultDispatcher from "../../dispatcher/dispatcher"; import type LegacyCallEventGrouper from "./LegacyCallEventGrouper"; import WhoIsTypingTile from "../views/rooms/WhoIsTypingTile"; -import ScrollPanel, { type IScrollState } from "./ScrollPanel"; +import { type IScrollState } from "./ScrollPanel"; +import { ListRange, LogLevel, Virtuoso, VirtuosoHandle } from "react-virtuoso"; import DateSeparator from "../views/messages/DateSeparator"; import TimelineSeparator, { SeparatorKind } from "../views/messages/TimelineSeparator"; import ErrorBoundary from "../views/elements/ErrorBoundary"; @@ -58,6 +59,15 @@ import { getLateEventInfo } from "./grouper/LateEventGrouper"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; +type EventItem = { + prevEvent: MatrixEvent | null; + wrappedEvent: WrappedEvent; + last: boolean; + isGrouped: boolean; + nextEvent: WrappedEvent | null; + nextEventWithTile: MatrixEvent | null; +}; + // 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( @@ -192,6 +202,7 @@ interface IState { ghostReadMarkers: string[]; showTypingNotifications: boolean; hideSender: boolean; + isScrolling: boolean; } interface IReadReceiptForUser { @@ -251,7 +262,7 @@ export default class MessagePanel extends React.Component { private readMarkerNode = createRef(); private whoIsTyping = createRef(); - public scrollPanel = createRef(); + private virtuosoRef = createRef(); private showTypingNotificationsWatcherRef?: string; private eventTiles: Record = {}; @@ -259,6 +270,10 @@ export default class MessagePanel extends React.Component { // A map to allow groupers to maintain consistent keys even if their first event is uprooted due to back-pagination. public grouperKeyMap = new WeakMap(); + private initialIndex = 100000; + private items: EventItem[] = []; + private scrollingTimeout: number | undefined = undefined; + public constructor(props: IProps) { super(props); @@ -268,6 +283,7 @@ export default class MessagePanel extends React.Component { ghostReadMarkers: [], showTypingNotifications: SettingsStore.getValue("showTypingNotifications"), hideSender: this.shouldHideSender(), + isScrolling: false, }; // Cache these settings on mount since Settings is expensive to query, @@ -362,7 +378,8 @@ export default class MessagePanel extends React.Component { /* return true if the content is fully scrolled down right now; else false. */ public isAtBottom(): boolean | undefined { - return this.scrollPanel.current?.isAtBottom(); + return false; + // return this.scrollPanel.current?.isAtBottom(); } /* get the current scroll state. See ScrollPanel.getScrollState for @@ -371,7 +388,8 @@ export default class MessagePanel extends React.Component { * returns null if we are not mounted. */ public getScrollState(): IScrollState | null { - return this.scrollPanel.current?.getScrollState() ?? null; + return null; + // return this.scrollPanel.current?.getScrollState() ?? null; } // returns one of: @@ -382,36 +400,43 @@ export default class MessagePanel extends React.Component { // +1: read marker is below the window public getReadMarkerPosition(): number | null { const readMarker = this.readMarkerNode.current; - const messageWrapper = this.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; - } + return null; + // const messageWrapper = this.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; + // } } /* jump to the top of the content. */ public scrollToTop(): void { - this.scrollPanel.current?.scrollToTop(); + // console.log("scrollToTop"); + // this.virtuosoRef.current?.scrollIntoView({ index: 0, align: "start" }); + // this.virtuosoRef.current?.scrollToIndex() + // this.scrollPanel.current?.scrollToTop(); } /* jump to the bottom of the content. */ public scrollToBottom(): void { - this.scrollPanel.current?.scrollToBottom(); + // console.log("scrollToBottom"); + // this.virtuosoRef.current?.scrollIntoView({ index: this.items.length - 1, align: "end" }); + // this.scrollPanel.current?.scrollToBottom(); } /** @@ -420,7 +445,7 @@ export default class MessagePanel extends React.Component { * @param {KeyboardEvent} ev: the keyboard event to handle */ public handleScrollKey(ev: React.KeyboardEvent | KeyboardEvent): void { - this.scrollPanel.current?.handleScrollKey(ev); + // this.scrollPanel.current?.handleScrollKey(ev); } /* jump to the given event id. @@ -434,17 +459,18 @@ export default class MessagePanel extends React.Component { * defaults to 0. */ public scrollToEvent(eventId: string, pixelOffset?: number, offsetBase?: number): void { - this.scrollPanel.current?.scrollToToken(eventId, pixelOffset, offsetBase); + // this.scrollPanel.current?.scrollToToken(eventId, pixelOffset, offsetBase); } public scrollToEventIfNeeded(eventId: string): void { - const node = this.getNodeForEventId(eventId); - if (node) { - node.scrollIntoView({ - block: "nearest", - behavior: "instant", - }); - } + console.log("scrollToEventIfNeeded"); + // const node = this.getNodeForEventId(eventId); + // if (node) { + // node.scrollIntoView({ + // block: "nearest", + // behavior: "instant", + // }); + // } } private isUnmounting = (): boolean => { @@ -605,7 +631,7 @@ export default class MessagePanel extends React.Component { return !status || status === EventStatus.SENT; } - private getEventTiles(): ReactNode[] { + private getEventItems(): EventItem[] { // 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) @@ -651,7 +677,7 @@ export default class MessagePanel extends React.Component { } } - const ret: ReactNode[] = []; + const ret: EventItem[] = []; let prevEvent: MatrixEvent | null = null; // the last event we showed // Note: the EventTile might still render a "sent/sending receipt" independent of @@ -662,7 +688,7 @@ export default class MessagePanel extends React.Component { this.readReceiptsByEvent = this.getReadReceiptsByShownEvent(events); } - let grouper: BaseGrouper | null = null; + // let grouper: BaseGrouper | null = null; for (let i = 0; i < events.length; i++) { const wrappedEvent = events[i]; @@ -671,60 +697,58 @@ export default class MessagePanel extends React.Component { const last = event === lastShownEvent; const { nextEventAndShouldShow, nextTile } = this.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(this, wrappedEvent) && !this.props.disableGrouping) { - grouper = new Grouper( - this, - 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( - ...this.getTilesForEvent( - prevEvent, - wrappedEvent, - last, - false, - nextEventAndShouldShow, - nextTile, - ), - ); - prevEvent = event; - } - - const readMarker = this.readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex); - if (readMarker) ret.push(readMarker); + // 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(this, wrappedEvent) && !this.props.disableGrouping) { + // grouper = new Grouper( + // this, + // 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({ + prevEvent, + wrappedEvent, + last, + isGrouped: false, + nextEvent: nextEventAndShouldShow, + nextEventWithTile: nextTile, + }); + prevEvent = event; } - } - if (grouper) { - ret.push(...grouper.getTiles()); + // const readMarker = this.readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex); + // if (readMarker) ret.push(readMarker); + // } } + // if (grouper) { + // ret.push(...grouper.getTiles()); + // } + // console.log(`Rendering event tiles ${ret.length}`); return ret; } @@ -735,6 +759,7 @@ export default class MessagePanel extends React.Component { isGrouped = false, nextEvent: WrappedEvent | null = null, nextEventWithTile: MatrixEvent | null = null, + isScrolling: boolean, ): ReactNode[] { const mxEv = wrappedEvent.event; const ret: ReactNode[] = []; @@ -819,6 +844,7 @@ export default class MessagePanel extends React.Component { showReadReceipts={this.props.showReadReceipts} callEventGrouper={callEventGrouper} hideSender={this.state.hideSender} + isScrolling={isScrolling} />, ); @@ -956,60 +982,118 @@ export default class MessagePanel extends React.Component { // Once dynamic content in the events load, make the scrollPanel check the scroll offsets. public onHeightChanged = (): void => { - this.scrollPanel.current?.checkScroll(); + // this.scrollPanel.current?.checkScroll(); }; private resizeObserver = new ResizeObserver(this.onHeightChanged); private onTypingShown = (): void => { - const scrollPanel = this.scrollPanel.current; - // this will make the timeline grow, so checkScroll - scrollPanel?.checkScroll(); - if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { - scrollPanel.preventShrinking(); - } + // const scrollPanel = this.scrollPanel.current; + // // this will make the timeline grow, so checkScroll + // scrollPanel?.checkScroll(); + // if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { + // scrollPanel.preventShrinking(); + // } }; private onTypingHidden = (): void => { - const scrollPanel = this.scrollPanel.current; - if (scrollPanel) { - // as hiding the typing notifications doesn't - // update the scrollPanel, we tell it to apply - // the shrinking prevention once the typing notifs are hidden - scrollPanel.updatePreventShrinking(); - // order is important here as checkScroll will scroll down to - // reveal added padding to balance the notifs disappearing. - scrollPanel.checkScroll(); - } + // const scrollPanel = this.scrollPanel.current; + // if (scrollPanel) { + // // as hiding the typing notifications doesn't + // // update the scrollPanel, we tell it to apply + // // the shrinking prevention once the typing notifs are hidden + // scrollPanel.updatePreventShrinking(); + // // order is important here as checkScroll will scroll down to + // // reveal added padding to balance the notifs disappearing. + // scrollPanel.checkScroll(); + // } }; public updateTimelineMinHeight(): void { - const scrollPanel = this.scrollPanel.current; - - if (scrollPanel) { - const isAtBottom = scrollPanel.isAtBottom(); - const whoIsTyping = this.whoIsTyping.current; - const isTypingVisible = whoIsTyping && whoIsTyping.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) { - scrollPanel.preventShrinking(); - } - } + // const scrollPanel = this.scrollPanel.current; + // if (scrollPanel) { + // const isAtBottom = scrollPanel.isAtBottom(); + // const whoIsTyping = this.whoIsTyping.current; + // const isTypingVisible = whoIsTyping && whoIsTyping.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) { + // scrollPanel.preventShrinking(); + // } + // } } public onTimelineReset(): void { - const scrollPanel = this.scrollPanel.current; - if (scrollPanel) { - scrollPanel.clearPreventShrinking(); + // const scrollPanel = this.scrollPanel.current; + // if (scrollPanel) { + // scrollPanel.clearPreventShrinking(); + // } + } + private readonly pendingFillRequests: Record<"b" | "f", boolean | null> = { + b: null, + f: null, + }; + // check if there is already a pending fill request. If not, set one off. + private maybeFill(backwards: boolean): Promise { + const dir = backwards ? "b" : "f"; + if (this.pendingFillRequests[dir]) { + console.log("Already a fill in progress - not starting another; direction=", dir); + return Promise.resolve(); } + + console.log("starting fill; direction=", dir); + + // onFillRequest can end up calling us recursively (via onScroll + // events) so make sure we set this before firing off the call. + this.pendingFillRequests[dir] = true; + + // wait 1ms before paginating, because otherwise + // this will block the scroll event handler for +700ms + // if messages are already cached in memory, + // This would cause jumping to happen on Chrome/macOS. + return new Promise((resolve) => window.setTimeout(resolve, 1)) + .then(() => { + return this.props.onFillRequest?.(backwards); + }) + .finally(() => { + this.pendingFillRequests[dir] = false; + }) + .then((hasMoreResults) => { + if (this.unmounted) { + return; + } + + console.log("fill complete; hasMoreResults=", hasMoreResults, "direction=", dir); + if (hasMoreResults) { + // further pagination requests have been disabled until now, so + // it's time to check the fill state again in case the pagination + // was insufficient. + // return this.checkFillState(depth + 1); + } + }); } + private onStartReached = (index: number): void => { + // setTimeout(() => { + console.log("onStartReached"); + console.log(index); + this.maybeFill(true); + // }, 10); + }; + + // private setVisibleRange = (range: ListRange): void => { + // if (range.startIndex == 0) { + // // this.props.onFillRequest?.(true); + // this.maybeFill(true); + // } + // console.log(`VisibleRange: ${range.startIndex} : ${range.endIndex}`); + // }; + public render(): React.ReactNode { - let topSpinner; - let bottomSpinner; + let topSpinner: ReactNode; + let bottomSpinner: ReactNode; if (this.props.backPaginating) { topSpinner = (
  • @@ -1054,9 +1138,82 @@ export default class MessagePanel extends React.Component { mx_MessagePanel_narrow: this.context.narrow, }); + // const InnerEventTiles = React.memo((e: EventItem) => { + // React.useEffect(() => { + // console.log("inner mounting", e); + // return () => { + // console.log("inner unmounting", e); + // }; + // }, [e]); + // return this.getTilesForEvent( + // e.prevEvent, + // e.wrappedEvent, + // e.last, + // e.isGrouped, + // e.nextEvent, + // e.nextEventWithTile, + // ); + // }); + + const newItems = this.getEventItems(); + const diff = newItems.length - this.items.length; + this.initialIndex -= diff; + this.items = newItems; return ( - +
      + { + if (isScrolling && !this.state.isScrolling) { + this.setState({ isScrolling }); + return; + } + clearTimeout(this.scrollingTimeout); + this.scrollingTimeout = window.setTimeout(() => { + if (this.state.isScrolling != isScrolling) { + this.setState({ isScrolling }); + } + }, 1000); + }} + // rangeChanged={this.setVisibleRange} + startReached={this.onStartReached} + // increaseViewportBy={{ top: 3000, bottom: 3000 }} + overscan={{ main: 1000, reverse: 1000 }} + itemContent={(i, e) => + //
      + // + // {e.wrappedEvent.event.getContent().body} {i} + // + //
      + // ) + + this.getTilesForEvent( + e.prevEvent, + e.wrappedEvent, + e.last, + e.isGrouped, + e.nextEvent, + e.nextEventWithTile, + // true, + this.state.isScrolling, + ) + } + components={{ Header: () => topSpinner, Footer: () => bottomSpinner }} + // onScroll={(e) => "ONSCROLL!"} + /> +
    + + + {/* { {this.getEventTiles()} {whoIsTyping} {bottomSpinner} - +
    */}
    ); } diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 8346a0ab319..bdea603fb29 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -380,7 +380,8 @@ class TimelinePanel extends React.Component { } private get messagePanelDiv(): HTMLDivElement | null { - return this.messagePanel.current?.scrollPanel.current?.divScroll ?? null; + return null; + // return this.messagePanel.current?.scrollPanel.current?.divScroll ?? null; } /** diff --git a/src/components/views/messages/IBodyProps.ts b/src/components/views/messages/IBodyProps.ts index 37aae37de6d..21af8ec484d 100644 --- a/src/components/views/messages/IBodyProps.ts +++ b/src/components/views/messages/IBodyProps.ts @@ -48,4 +48,5 @@ export interface IBodyProps { // Set to `true` to disable interactions (e.g. video controls) and to remove controls from the tab order. // This may be useful when displaying a preview of the event. inhibitInteraction?: boolean; + isScrolling?: boolean; } diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 231fa7f1fe5..53028ca841b 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -308,6 +308,7 @@ export default class MessageEvent extends React.Component implements IMe getRelationsForEvent: this.props.getRelationsForEvent, isSeeingThroughMessageHiddenForModeration: this.props.isSeeingThroughMessageHiddenForModeration, inhibitInteraction: this.props.inhibitInteraction, + isScrolling: this.props.isScrolling, }; if (hasCaption) { return ; @@ -320,9 +321,12 @@ export default class MessageEvent extends React.Component implements IMe const CaptionBody: React.FunctionComponent }> = ({ WrappedBodyType, ...props -}) => ( -
    - - -
    -); +}) => { + console.log(`CaptionBody isScrolling${props.isScrolling}`); + return ( +
    + + +
    + ); +}; diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index d0107b31ecc..d92c1f338ce 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -83,7 +83,9 @@ export default class TextualBody extends React.Component { nextProps.editState !== this.props.editState || nextState.links !== this.state.links || nextState.widgetHidden !== this.state.widgetHidden || - nextProps.isSeeingThroughMessageHiddenForModeration !== this.props.isSeeingThroughMessageHiddenForModeration + nextProps.isSeeingThroughMessageHiddenForModeration !== + this.props.isSeeingThroughMessageHiddenForModeration || + nextProps.isScrolling !== this.props.isScrolling ); } @@ -378,6 +380,7 @@ export default class TextualBody extends React.Component { links={this.state.links} mxEvent={this.props.mxEvent} onCancelClick={this.onCancelClick} + isScrolling={this.props.isScrolling} /> ); } diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 2c10d0afd96..1ebcdcb766b 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -230,6 +230,8 @@ export interface EventTileProps { inhibitInteraction?: boolean; ref?: Ref; + + isScrolling?: boolean; } interface IState { @@ -1049,7 +1051,7 @@ export class UnwrappedEventTile extends React.Component needsSenderProfile = true; } - if (this.props.mxEvent.sender && avatarSize !== null) { + if (this.props.mxEvent.sender && avatarSize !== null && !this.props.isScrolling) { let member: RoomMember | null = null; // set member to receiver (target) if it is a 3PID invite // so that the correct avatar is shown as the text is @@ -1077,7 +1079,7 @@ export class UnwrappedEventTile extends React.Component ); } - if (needsSenderProfile && this.props.hideSender !== true) { + if (needsSenderProfile && this.props.hideSender !== true && !this.props.isScrolling) { if ( this.context.timelineRenderingType === TimelineRenderingType.Room || this.context.timelineRenderingType === TimelineRenderingType.Search || @@ -1093,19 +1095,20 @@ export class UnwrappedEventTile extends React.Component } const showMessageActionBar = !isEditing && !this.props.forExport; - const actionBar = showMessageActionBar ? ( - this.setQuoteExpanded(!isQuoteExpanded)} - getRelationsForEvent={this.props.getRelationsForEvent} - /> - ) : undefined; + const actionBar = + showMessageActionBar && !this.props.isScrolling ? ( + this.setQuoteExpanded(!isQuoteExpanded)} + getRelationsForEvent={this.props.getRelationsForEvent} + /> + ) : undefined; const showTimestamp = this.props.mxEvent.getTs() && @@ -1168,14 +1171,16 @@ export class UnwrappedEventTile extends React.Component ) : null; const useIRCLayout = this.props.layout === Layout.IRC; - const groupTimestamp = !useIRCLayout ? linkedTimestamp : null; - const ircTimestamp = useIRCLayout ? linkedTimestamp : null; + const groupTimestamp = !useIRCLayout && !this.props.isScrolling ? linkedTimestamp : null; + const ircTimestamp = useIRCLayout && !this.props.isScrolling ? linkedTimestamp : null; const bubbleTimestamp = this.props.layout === Layout.Bubble ? messageTimestamp : undefined; - const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); - const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); + const groupPadlock = !useIRCLayout && !isBubbleMessage && !this.props.isScrolling && this.renderE2EPadlock(); + const ircPadlock = useIRCLayout && !isBubbleMessage && !this.props.isScrolling && this.renderE2EPadlock(); let msgOption: JSX.Element | undefined; - if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { + if (this.props.isScrolling) { + msgOption = undefined; + } else if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { msgOption = ; } else if (this.props.showReadReceipts) { msgOption = ( @@ -1192,7 +1197,8 @@ export class UnwrappedEventTile extends React.Component let replyChain: JSX.Element | undefined; if ( haveRendererForEvent(this.props.mxEvent, MatrixClientPeg.safeGet(), this.context.showHiddenEvents) && - shouldDisplayReply(this.props.mxEvent) + shouldDisplayReply(this.props.mxEvent) && + !this.props.isScrolling ) { replyChain = ( } default: { + const contextMenu = this.props.isScrolling ? null : this.renderContextMenu(); // Pinned, Room, Search // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers return React.createElement( @@ -1429,7 +1436,7 @@ export class UnwrappedEventTile extends React.Component {ircPadlock} {avatar}
    - {this.renderContextMenu()} + {contextMenu} {groupTimestamp} {groupPadlock} {replyChain} diff --git a/src/components/views/rooms/LinkPreviewGroup.tsx b/src/components/views/rooms/LinkPreviewGroup.tsx index 69c98cb6c9a..6d0888dffa7 100644 --- a/src/components/views/rooms/LinkPreviewGroup.tsx +++ b/src/components/views/rooms/LinkPreviewGroup.tsx @@ -25,9 +25,10 @@ interface IProps { links: string[]; // the URLs to be previewed mxEvent: MatrixEvent; // the Event associated with the preview onCancelClick(): void; // called when the preview's cancel ('hide') button is clicked + isScrolling?: boolean; } -const LinkPreviewGroup: React.FC = ({ links, mxEvent, onCancelClick }) => { +const LinkPreviewGroup: React.FC = ({ links, mxEvent, onCancelClick, isScrolling }) => { const cli = useContext(MatrixClientContext); const [expanded, toggleExpanded] = useStateToggle(); const [mediaVisible] = useMediaVisible(mxEvent.getId(), mxEvent.getRoomId()); @@ -55,28 +56,30 @@ const LinkPreviewGroup: React.FC = ({ links, mxEvent, onCancelClick }) = } return ( -
    - {showPreviews.map(([link, preview], i) => ( - - {i === 0 ? ( - - - - ) : undefined} - - ))} - {toggleButton} -
    + !isScrolling && ( +
    + {showPreviews.map(([link, preview], i) => ( + + {i === 0 ? ( + + + + ) : undefined} + + ))} + {toggleButton} +
    + ) ); }; diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index f1fc224471c..c857a83ccf6 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -60,6 +60,7 @@ export interface EventTileTypeProps | "callEventGrouper" | "isSeeingThroughMessageHiddenForModeration" | "inhibitInteraction" + | "isScrolling" > { ref?: React.RefObject; // `any` because it's effectively impossible to convince TS of a reasonable type timestamp?: JSX.Element; @@ -278,6 +279,7 @@ export function renderTile( isSeeingThroughMessageHiddenForModeration, timestamp, inhibitInteraction, + isScrolling, } = props; switch (renderType) { @@ -313,6 +315,7 @@ export function renderTile( isSeeingThroughMessageHiddenForModeration, timestamp, inhibitInteraction, + isScrolling, }); } } diff --git a/yarn.lock b/yarn.lock index 4bc72c36799..24ff31c3c7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3720,15 +3720,16 @@ classnames "^2.5.1" vaul "^1.0.0" -"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm": +"@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm": version "0.0.0" + uid "" "@vector-im/matrix-wysiwyg@2.38.3": version "2.38.3" resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.38.3.tgz#cc54d8b3e9472bcd8e622126ba364ee31952cd8a" integrity sha512-fqo8P55Vc/t0vxpFar9RDJN5gKEjJmzrLo+O4piDbFda6VrRoqrWAtiu0Au0g6B4hRDPKIuFupk8v9Ja7q8Hvg== dependencies: - "@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm" + "@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm" "@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": version "1.14.1" @@ -11060,6 +11061,11 @@ react-virtualized@^9.22.5: prop-types "^15.7.2" react-lifecycles-compat "^3.0.4" +react-virtuoso@^4.12.7: + version "4.12.7" + resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.12.7.tgz#300f2585c61d213d4d422420f0d43ffc9674e6f5" + integrity sha512-njJp764he6Fi1p89PUW0k2kbyWu9w/y+MwdxmwK2kvdwwzVDbz2c2wMj5xdSruBFVgFTsI7Z85hxZR7aSHBrbQ== + react@^19.0.0: version "19.1.0" resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75"