Replace ScrollPanel with Virtuoso

- Support back pagination and crudely hide images when scrolling
This commit is contained in:
David Langley 2025-05-14 10:31:52 +01:00
parent 08acbf9b14
commit 461fb69960
10 changed files with 362 additions and 176 deletions

View File

@ -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",

View File

@ -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<IProps, IState> {
private readMarkerNode = createRef<HTMLLIElement>();
private whoIsTyping = createRef<WhoIsTypingTile>();
public scrollPanel = createRef<ScrollPanel>();
private virtuosoRef = createRef<VirtuosoHandle | null>();
private showTypingNotificationsWatcherRef?: string;
private eventTiles: Record<string, UnwrappedEventTile> = {};
@ -259,6 +270,10 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// A map to allow groupers to maintain consistent keys even if their first event is uprooted due to back-pagination.
public grouperKeyMap = new WeakMap<MatrixEvent, string>();
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<IProps, IState> {
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<IProps, IState> {
/* 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<IProps, IState> {
* 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<IProps, IState> {
// +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;
}
return null;
// const messageWrapper = this.scrollPanel.current?.divScroll;
const wrapperRect = messageWrapper.getBoundingClientRect();
const readMarkerRect = readMarker.getBoundingClientRect();
// if (!readMarker || !messageWrapper) {
// return null;
// }
// 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;
}
// 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<IProps, IState> {
* @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<IProps, IState> {
* 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<IProps, IState> {
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<IProps, IState> {
}
}
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<IProps, IState> {
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<IProps, IState> {
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;
}
// 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;
}
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) {
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<IProps, IState> {
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<IProps, IState> {
showReadReceipts={this.props.showReadReceipts}
callEventGrouper={callEventGrouper}
hideSender={this.state.hideSender}
isScrolling={isScrolling}
/>,
);
@ -956,60 +982,118 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// 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<void> {
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 = (
<li key="_topSpinner">
@ -1054,9 +1138,82 @@ export default class MessagePanel extends React.Component<IProps, IState> {
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 (
<ErrorBoundary>
<ScrollPanel
{/* {ircResizer} */}
<div style={{ height: "100%" }} className="mx_RoomView_messageListWrapper">
<ol style={{ height: "100%" }} className="mx_RoomView_MessageList" aria-live="polite">
<Virtuoso
ref={this.virtuosoRef}
className={classes}
style={style}
firstItemIndex={this.initialIndex}
data={this.items}
alignToBottom={true}
// logLevel={LogLevel.DEBUG}
isScrolling={(isScrolling) => {
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) =>
// <div>
// <span>
// {e.wrappedEvent.event.getContent().body} {i}
// </span>
// </div>
// )
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!"}
/>
</ol>
</div>
{/* <ScrollPanel
ref={this.scrollPanel}
className={classes}
onScroll={this.props.onScroll}
@ -1071,7 +1228,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
{this.getEventTiles()}
{whoIsTyping}
{bottomSpinner}
</ScrollPanel>
</ScrollPanel> */}
</ErrorBoundary>
);
}

View File

@ -380,7 +380,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
private get messagePanelDiv(): HTMLDivElement | null {
return this.messagePanel.current?.scrollPanel.current?.divScroll ?? null;
return null;
// return this.messagePanel.current?.scrollPanel.current?.divScroll ?? null;
}
/**

View File

@ -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;
}

View File

@ -308,6 +308,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
getRelationsForEvent: this.props.getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration: this.props.isSeeingThroughMessageHiddenForModeration,
inhibitInteraction: this.props.inhibitInteraction,
isScrolling: this.props.isScrolling,
};
if (hasCaption) {
return <CaptionBody {...bodyProps} WrappedBodyType={BodyType} />;
@ -320,9 +321,12 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
const CaptionBody: React.FunctionComponent<IBodyProps & { WrappedBodyType: React.ComponentType<IBodyProps> }> = ({
WrappedBodyType,
...props
}) => (
<div className="mx_EventTile_content">
<WrappedBodyType {...props} />
<TextualBody {...{ ...props, ref: undefined }} />
</div>
);
}) => {
console.log(`CaptionBody isScrolling${props.isScrolling}`);
return (
<div className="mx_EventTile_content">
<WrappedBodyType {...props} />
<TextualBody {...{ ...props, ref: undefined }} />
</div>
);
};

View File

@ -83,7 +83,9 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
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<IBodyProps, IState> {
links={this.state.links}
mxEvent={this.props.mxEvent}
onCancelClick={this.onCancelClick}
isScrolling={this.props.isScrolling}
/>
);
}

View File

@ -230,6 +230,8 @@ export interface EventTileProps {
inhibitInteraction?: boolean;
ref?: Ref<UnwrappedEventTile>;
isScrolling?: boolean;
}
interface IState {
@ -1049,7 +1051,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
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<EventTileProps, IState>
);
}
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<EventTileProps, IState>
}
const showMessageActionBar = !isEditing && !this.props.forExport;
const actionBar = showMessageActionBar ? (
<MessageActionBar
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator}
getTile={this.getTile}
getReplyChain={this.getReplyChain}
onFocusChange={this.onActionBarFocusChange}
isQuoteExpanded={isQuoteExpanded}
toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)}
getRelationsForEvent={this.props.getRelationsForEvent}
/>
) : undefined;
const actionBar =
showMessageActionBar && !this.props.isScrolling ? (
<MessageActionBar
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator}
getTile={this.getTile}
getReplyChain={this.getReplyChain}
onFocusChange={this.onActionBarFocusChange}
isQuoteExpanded={isQuoteExpanded}
toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)}
getRelationsForEvent={this.props.getRelationsForEvent}
/>
) : undefined;
const showTimestamp =
this.props.mxEvent.getTs() &&
@ -1168,14 +1171,16 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
) : 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 = <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />;
} else if (this.props.showReadReceipts) {
msgOption = (
@ -1192,7 +1197,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
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 = (
<ReplyChain
@ -1405,6 +1411,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}
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<EventTileProps, IState>
{ircPadlock}
{avatar}
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
{this.renderContextMenu()}
{contextMenu}
{groupTimestamp}
{groupPadlock}
{replyChain}

View File

@ -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<IProps> = ({ links, mxEvent, onCancelClick }) => {
const LinkPreviewGroup: React.FC<IProps> = ({ 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<IProps> = ({ links, mxEvent, onCancelClick }) =
}
return (
<div className="mx_LinkPreviewGroup">
{showPreviews.map(([link, preview], i) => (
<LinkPreviewWidget
mediaVisible={mediaVisible}
key={link}
link={link}
preview={preview}
mxEvent={mxEvent}
>
{i === 0 ? (
<AccessibleButton
className="mx_LinkPreviewGroup_hide"
onClick={onCancelClick}
aria-label={_t("timeline|url_preview|close")}
>
<CloseIcon width="20px" height="20px" />
</AccessibleButton>
) : undefined}
</LinkPreviewWidget>
))}
{toggleButton}
</div>
!isScrolling && (
<div className="mx_LinkPreviewGroup">
{showPreviews.map(([link, preview], i) => (
<LinkPreviewWidget
mediaVisible={mediaVisible}
key={link}
link={link}
preview={preview}
mxEvent={mxEvent}
>
{i === 0 ? (
<AccessibleButton
className="mx_LinkPreviewGroup_hide"
onClick={onCancelClick}
aria-label={_t("timeline|url_preview|close")}
>
<CloseIcon width="20px" height="20px" />
</AccessibleButton>
) : undefined}
</LinkPreviewWidget>
))}
{toggleButton}
</div>
)
);
};

View File

@ -60,6 +60,7 @@ export interface EventTileTypeProps
| "callEventGrouper"
| "isSeeingThroughMessageHiddenForModeration"
| "inhibitInteraction"
| "isScrolling"
> {
ref?: React.RefObject<any>; // `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,
});
}
}

View File

@ -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"