Timeline MVVM 1 - Shared TimelineView and Overlay Buttons (#34464)

* Add shared timeline overlay buttons and view contract

The TimelineOverlayButtons presentational component (unread bar, jump-to-bottom)
plus the shared TimelineView VM contract (types) and its i18n strings. Consumed
by the TimelineView virtualizer in a follow-up commit.

* Add shared TimelineView component (TanStack Virtual)

Headless TanStack-Virtual timeline driven from RoomTimelineViewModel: anchorTo:"end"
viewport stability across prepends/trims, cold-load cover, jump-to via scrollToOffset,
and visible-state reporting via the virtualizer's onChange. Adds the @tanstack/react-virtual
dependency and the virtual-core isValidAnchorItem patch (runtime + types).

* Use Compound spacing tokens in timeline overlay and clarify comments

- Use Compound spacing tokens for overlay margins/insets instead of raw px
- Drop the stale Virtuoso reference from the overlay CSS comment
- Note the real-world duration of COLD_CAP_FRAMES
- Describe each load phase in plain English

* Remove jargon and explain/expand things in simple terms.

* Use time rather than a frame count and improve description.
This commit is contained in:
David Langley
2026-08-12 08:40:26 +00:00
committed by GitHub
parent 23a666a9de
commit badb35bd1f
25 changed files with 1681 additions and 1 deletions
+1
View File
@@ -68,6 +68,7 @@
"@element-hq/element-web-module-api": "workspace:*",
"@matrix-org/emojibase-bindings": "catalog:",
"@matrix-org/spec": "^1.7.0",
"@tanstack/react-virtual": "3.14.8",
"@vector-im/compound-design-tokens": "catalog:",
"classnames": "^2.5.1",
"counterpart": "^0.18.6",
@@ -83,6 +83,7 @@
"notifications": {
"all_messages": "All messages",
"default_settings": "Match default settings",
"mark_all_read": "Mark all as read",
"mentions_keywords": "Mentions and keywords",
"message_didnt_send": "Message didn't send. Click for info.",
"mute_room": "Mute room"
@@ -101,6 +102,8 @@
"shared": "New members see history",
"world_readable": "Anyone can see history"
},
"jump_read_marker": "Jump to first unread message.",
"jump_to_bottom_button": "Scroll to most recent messages",
"jump_to_date": "Jump to date",
"jump_to_date_beginning": "The beginning of the room",
"jump_to_date_prompt": "Pick a date to jump to",
+1
View File
@@ -44,6 +44,7 @@ export * from "./room/HistoryVisibilityBadge";
export * from "./room/right-panel/WidgetContextMenuView";
export * from "./room/timeline/DateSeparatorView";
export * from "./room/timeline/TimelineSeparator";
export * from "./room/timeline/TimelineView";
export * from "./room/timeline/event-tile/actions/ActionBarView";
export * from "./room/timeline/event-tile/EventTileView/DisambiguatedProfile";
export * from "./room/timeline/event-tile/EventTileView/E2eMessageSharedIcon";
@@ -0,0 +1,201 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* 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.
*/
/**
* Overlay layer — absolutely fills the timeline so buttons can be positioned at
* the top or bottom without affecting list layout.
* Children use their own absolute positioning to match the legacy components.
*/
.overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1000;
}
/* ── Top unread bar — matches legacy TopUnreadMessagesBar ────────── */
.topUnreadBar {
position: absolute;
top: var(--cpd-space-6x);
right: var(--cpd-space-6x);
width: 38px;
pointer-events: all;
}
.topUnreadBar::after {
content: "";
position: absolute;
top: -8px;
left: 11px;
width: 4px;
height: 4px;
border-radius: 16px;
background-color: var(--cpd-color-bg-canvas-default);
border: 6px solid var(--cpd-color-icon-accent-tertiary);
pointer-events: none;
}
.topUnreadBarScrollUp {
display: flex;
align-items: center;
justify-content: center;
height: 38px;
width: 38px;
border-radius: 19px;
box-sizing: border-box;
background: var(--cpd-color-bg-canvas-default);
border: 1.3px solid var(--cpd-color-icon-tertiary);
cursor: pointer;
}
.topUnreadBarScrollUp svg {
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--cpd-color-icon-tertiary);
}
.topUnreadBarMarkAsRead {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: var(--cpd-color-bg-canvas-default);
border: 1.3px solid var(--cpd-color-icon-tertiary);
border-radius: 10px;
margin: 5px auto;
cursor: pointer;
}
.topUnreadBarMarkAsRead svg {
width: 12px;
height: 12px;
flex-shrink: 0;
color: var(--cpd-color-icon-tertiary);
}
/* ── Below unread bar — marker is below viewport; appears at top-right ── */
.belowUnreadBar {
position: absolute;
top: var(--cpd-space-6x);
right: var(--cpd-space-6x);
width: 38px;
pointer-events: all;
}
/* Green dot just below the chevron button (bottom edge at 38px), hinting the marker is downward */
.belowUnreadBar::after {
content: "";
position: absolute;
top: 30px;
left: 11px;
width: 4px;
height: 4px;
border-radius: 16px;
background-color: var(--cpd-color-bg-canvas-default);
border: 6px solid var(--cpd-color-icon-accent-tertiary);
pointer-events: none;
}
.belowUnreadBarScrollDown {
display: flex;
align-items: center;
justify-content: center;
height: 38px;
width: 38px;
border-radius: 19px;
box-sizing: border-box;
background: var(--cpd-color-bg-canvas-default);
border: 1.3px solid var(--cpd-color-icon-tertiary);
cursor: pointer;
}
.belowUnreadBarScrollDown svg {
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--cpd-color-icon-tertiary);
}
.belowUnreadBarMarkAsRead {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: var(--cpd-color-bg-canvas-default);
border: 1.3px solid var(--cpd-color-icon-tertiary);
border-radius: 10px;
/* dot centre is at top: 38px, radius 8px → extends to 46px; add 4px visual gap → 50px - 38px = 12px margin */
margin: var(--cpd-space-3x) auto 0;
cursor: pointer;
}
.belowUnreadBarMarkAsRead svg {
width: 12px;
height: 12px;
flex-shrink: 0;
color: var(--cpd-color-icon-tertiary);
}
/* ── Jump-to-bottom button — matches legacy JumpToBottomButton ───── */
.jumpToBottom {
position: absolute;
/* 12 because height is 50 but button is only 38 = 12+(50-38) = 24 */
bottom: var(--cpd-space-3x);
right: var(--cpd-space-6x);
width: 38px;
/* give it a fixed height so the badge doesn't make */
/* it taller and pop upwards when visible */
height: 50px;
text-align: center;
pointer-events: none;
}
.jumpToBottomBadge {
position: relative;
top: -12px;
border-radius: 16px;
font-weight: bold;
font-size: 12px;
line-height: 14px;
text-align: center;
display: inline-block;
padding: 0 var(--cpd-space-1x);
color: var(--cpd-color-text-on-solid-primary);
background-color: var(--cpd-color-icon-secondary);
}
.highlight .jumpToBottomBadge {
background-color: var(--cpd-color-icon-critical-primary);
}
.jumpToBottomScrollDown {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
box-sizing: border-box;
background: var(--cpd-color-bg-canvas-default);
border: 1.3px solid var(--cpd-color-icon-tertiary);
cursor: pointer;
pointer-events: all;
}
.jumpToBottomScrollDown svg {
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--cpd-color-icon-tertiary);
}
@@ -0,0 +1,104 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* 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 } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TimelineOverlayButtons } from "./TimelineOverlayButtons";
import type { ImmediateScroll, TimelineViewActions, TimelineViewSnapshot } from "./types";
// The overlay takes an already-split snapshot + actions (not a ViewModel), so the
// story flattens them into props — this lets interaction tests override a single
// action (e.g. onJumpToLive) without rebuilding the whole object.
type OverlayStoryProps = TimelineViewSnapshot & TimelineViewActions & { scrollNow: ImmediateScroll };
const TimelineOverlayButtonsWrapper = ({
onStartReached,
onEndReached,
onAnchorReached,
onVisibleRangeChanged,
onAtBottomStateChange,
onJumpToReadMarker,
onMarkAllAsRead,
onJumpToLive,
scrollNow,
...snapshot
}: OverlayStoryProps): JSX.Element => {
const vm: TimelineViewActions = {
onStartReached,
onEndReached,
onAnchorReached,
onVisibleRangeChanged,
onAtBottomStateChange,
onJumpToReadMarker,
onMarkAllAsRead,
onJumpToLive,
};
return <TimelineOverlayButtons snapshot={snapshot} vm={vm} scrollNow={scrollNow} />;
};
const meta = {
title: "Timeline/TimelineOverlayButtons",
component: TimelineOverlayButtonsWrapper,
tags: ["autodocs"],
args: {
// Snapshot defaults: scrolled up off the live end so the jump-to-bottom shows.
items: [],
atLiveEnd: false,
pendingAnchor: null,
highlightedEventId: null,
isAtBottom: false,
canJumpToReadMarker: false,
numUnreadMessages: 0,
hasHighlights: false,
// Actions.
onStartReached: fn(),
onEndReached: fn(),
onAnchorReached: fn(),
onVisibleRangeChanged: fn(),
onAtBottomStateChange: fn(),
onJumpToReadMarker: fn(),
onMarkAllAsRead: fn(),
onJumpToLive: fn(),
scrollNow: fn(),
},
// The overlay absolutely fills its positioned parent; give it a sized box to sit in.
decorators: [
(Story) => (
<div style={{ position: "relative", width: "360px", height: "260px", border: "1px solid #ccc" }}>
<Story />
</div>
),
],
} satisfies Meta<typeof TimelineOverlayButtonsWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Scrolled up off the live end: the jump-to-bottom button, no unread. */
export const JumpToBottom: Story = {};
/** Jump-to-bottom carrying an unread-message badge. */
export const JumpToBottomWithUnread: Story = {
args: { numUnreadMessages: 5 },
};
/** Jump-to-bottom with the highlight (mention/keyword) colouring. */
export const JumpToBottomWithHighlight: Story = {
args: { numUnreadMessages: 3, hasHighlights: true },
};
/** Read marker sits above the viewport: scroll-up + mark-as-read bar (top-right). */
export const UnreadMarkerAbove: Story = {
args: { atLiveEnd: true, isAtBottom: true, canJumpToReadMarker: "above" },
};
/** Read marker sits below the viewport: scroll-down + mark-as-read bar. */
export const UnreadMarkerBelow: Story = {
args: { atLiveEnd: true, isAtBottom: true, canJumpToReadMarker: "below" },
};
@@ -0,0 +1,179 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* 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 from "react";
import { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { TimelineOverlayButtons } from "./TimelineOverlayButtons";
import type { TimelineViewActions, TimelineViewSnapshot } from "./types";
const baseSnapshot: TimelineViewSnapshot = {
items: [],
atLiveEnd: true,
pendingAnchor: null,
highlightedEventId: null,
isAtBottom: true,
canJumpToReadMarker: false,
numUnreadMessages: 0,
hasHighlights: false,
};
// vi.fn() carries a constructable signature that trips assignability to the
// action method types, so cast the record of spies to the interface.
function makeActions(): TimelineViewActions {
return {
onStartReached: vi.fn(),
onEndReached: vi.fn(),
onAnchorReached: vi.fn(),
onVisibleRangeChanged: vi.fn(),
onAtBottomStateChange: vi.fn(),
onJumpToReadMarker: vi.fn(),
onMarkAllAsRead: vi.fn(),
onJumpToLive: vi.fn(),
} as unknown as TimelineViewActions;
}
// The jump controls must be exposed to AT/keyboard (the overlay is not aria-hidden),
// so plain role queries (which exclude hidden elements) are expected to find them.
const button = (name: string): HTMLElement => screen.getByRole("button", { name });
const queryButton = (name: string): HTMLElement | null => screen.queryByRole("button", { name });
const JUMP_TO_BOTTOM = "Scroll to most recent messages";
const JUMP_READ_MARKER = "Jump to first unread message.";
const MARK_ALL_READ = "Mark all as read";
describe("<TimelineOverlayButtons />", () => {
it("renders no buttons at the live bottom with no read marker", () => {
const actions = makeActions();
render(<TimelineOverlayButtons snapshot={baseSnapshot} vm={actions} scrollNow={vi.fn()} />);
expect(queryButton(JUMP_TO_BOTTOM)).toBeNull();
expect(queryButton(JUMP_READ_MARKER)).toBeNull();
expect(queryButton(MARK_ALL_READ)).toBeNull();
});
describe("jump-to-bottom button", () => {
it("shows when not at the live end", () => {
render(
<TimelineOverlayButtons
snapshot={{ ...baseSnapshot, atLiveEnd: false }}
vm={makeActions()}
scrollNow={vi.fn()}
/>,
);
expect(button(JUMP_TO_BOTTOM)).toBeInTheDocument();
});
it("shows when at the live end but scrolled up off the bottom", () => {
render(
<TimelineOverlayButtons
snapshot={{ ...baseSnapshot, atLiveEnd: true, isAtBottom: false }}
vm={makeActions()}
scrollNow={vi.fn()}
/>,
);
expect(button(JUMP_TO_BOTTOM)).toBeInTheDocument();
});
it("calls onJumpToLive with the imperative scroll handle when clicked", async () => {
const user = userEvent.setup();
const actions = makeActions();
const scrollNow = vi.fn();
render(
<TimelineOverlayButtons
snapshot={{ ...baseSnapshot, isAtBottom: false }}
vm={actions}
scrollNow={scrollNow}
/>,
);
await user.click(button(JUMP_TO_BOTTOM));
expect(actions.onJumpToLive).toHaveBeenCalledWith(scrollNow);
});
it("renders the unread badge count", () => {
render(
<TimelineOverlayButtons
snapshot={{ ...baseSnapshot, isAtBottom: false, numUnreadMessages: 7 }}
vm={makeActions()}
scrollNow={vi.fn()}
/>,
);
expect(screen.getByText("7")).toBeInTheDocument();
});
it("omits the badge when there are no unread messages", () => {
render(
<TimelineOverlayButtons
snapshot={{ ...baseSnapshot, isAtBottom: false, numUnreadMessages: 0 }}
vm={makeActions()}
scrollNow={vi.fn()}
/>,
);
expect(screen.queryByText("0")).toBeNull();
});
it("applies the highlight style when there are highlight messages", () => {
const { container } = render(
<TimelineOverlayButtons
snapshot={{ ...baseSnapshot, isAtBottom: false, numUnreadMessages: 1, hasHighlights: true }}
vm={makeActions()}
scrollNow={vi.fn()}
/>,
);
expect(container.querySelector('[class*="highlight"]')).not.toBeNull();
});
});
describe("unread bar", () => {
it("shows scroll-up and mark-as-read when the marker is above the viewport", () => {
render(
<TimelineOverlayButtons
snapshot={{ ...baseSnapshot, canJumpToReadMarker: "above" }}
vm={makeActions()}
scrollNow={vi.fn()}
/>,
);
expect(button(JUMP_READ_MARKER)).toBeInTheDocument();
expect(button(MARK_ALL_READ)).toBeInTheDocument();
});
it("shows the bar when the marker is below the viewport", () => {
render(
<TimelineOverlayButtons
snapshot={{ ...baseSnapshot, canJumpToReadMarker: "below" }}
vm={makeActions()}
scrollNow={vi.fn()}
/>,
);
expect(button(JUMP_READ_MARKER)).toBeInTheDocument();
expect(button(MARK_ALL_READ)).toBeInTheDocument();
});
it("calls onJumpToReadMarker with the scroll handle and onMarkAllAsRead on click", async () => {
const user = userEvent.setup();
const actions = makeActions();
const scrollNow = vi.fn();
render(
<TimelineOverlayButtons
snapshot={{ ...baseSnapshot, canJumpToReadMarker: "above" }}
vm={actions}
scrollNow={scrollNow}
/>,
);
await user.click(button(JUMP_READ_MARKER));
expect(actions.onJumpToReadMarker).toHaveBeenCalledWith(scrollNow);
await user.click(button(MARK_ALL_READ));
expect(actions.onMarkAllAsRead).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,117 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* 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, { useCallback, type JSX } from "react";
import classNames from "classnames";
import { Tooltip } from "@vector-im/compound-web";
import { ChevronUpIcon, ChevronDownIcon, CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useI18n } from "../../../core/i18n/i18nContext";
import type { ImmediateScroll, TimelineViewActions, TimelineViewSnapshot } from "./types";
import styles from "./TimelineOverlayButtons.module.css";
interface TimelineOverlayButtonsProps {
snapshot: TimelineViewSnapshot;
vm: TimelineViewActions;
/** Imperative scroll-to-anchor handle from the parent View; the VM invokes it
* when the target is already loaded, else falls back to a pendingAnchor load. */
scrollNow: ImmediateScroll;
}
/**
* Absolutely-positioned overlay on top of the timeline list:
* - **Unread bar** — shown when `canJumpToReadMarker` is `"above"`/`"below"`; a
* scroll-to-marker + mark-as-read pair at the top-right.
* - **Jump-to-bottom** (bottom-right) — shown when not at the live bottom, with an
* optional unread badge and highlight colouring.
*/
export function TimelineOverlayButtons({ snapshot, vm, scrollNow }: TimelineOverlayButtonsProps): JSX.Element {
const { translate: _t } = useI18n();
const readMarkerDirection = snapshot.canJumpToReadMarker;
const showJumpToBottom = !snapshot.atLiveEnd || !snapshot.isAtBottom;
const onJumpToReadMarkerClick = useCallback(() => vm.onJumpToReadMarker(scrollNow), [vm, scrollNow]);
const onJumpToLiveClick = useCallback(() => vm.onJumpToLive(scrollNow), [vm, scrollNow]);
return (
// Non-interactive layer (pointer-events:none) so clicks fall through to the list;
// only the buttons re-enable them. Must NOT be aria-hidden — these are AT-reachable.
<div className={styles.overlay}>
{/* Top unread bar — marker is above the viewport */}
{readMarkerDirection === "above" && (
<div className={styles.topUnreadBar}>
<Tooltip description={_t("room|jump_read_marker")} placement="left">
<button
className={styles.topUnreadBarScrollUp}
aria-label={_t("room|jump_read_marker")}
onClick={onJumpToReadMarkerClick}
type="button"
>
<ChevronUpIcon />
</button>
</Tooltip>
<Tooltip description={_t("notifications|mark_all_read")} placement="left">
<button
className={styles.topUnreadBarMarkAsRead}
aria-label={_t("notifications|mark_all_read")}
onClick={() => vm.onMarkAllAsRead()}
type="button"
>
<CloseIcon />
</button>
</Tooltip>
</div>
)}
{/* Unread bar — marker is below the viewport; sits at top-right like the above bar */}
{readMarkerDirection === "below" && (
<div className={styles.belowUnreadBar}>
<Tooltip description={_t("room|jump_read_marker")} placement="left">
<button
className={styles.belowUnreadBarScrollDown}
aria-label={_t("room|jump_read_marker")}
onClick={onJumpToReadMarkerClick}
type="button"
>
<ChevronDownIcon />
</button>
</Tooltip>
<Tooltip description={_t("notifications|mark_all_read")} placement="left">
<button
className={styles.belowUnreadBarMarkAsRead}
aria-label={_t("notifications|mark_all_read")}
onClick={() => vm.onMarkAllAsRead()}
type="button"
>
<CloseIcon />
</button>
</Tooltip>
</div>
)}
{/* Jump-to-bottom button — matches legacy JumpToBottomButton */}
{showJumpToBottom && (
<div className={classNames(styles.jumpToBottom, { [styles.highlight]: snapshot.hasHighlights })}>
<Tooltip description={_t("room|jump_to_bottom_button")} placement="left">
<button
className={styles.jumpToBottomScrollDown}
aria-label={_t("room|jump_to_bottom_button")}
onClick={onJumpToLiveClick}
type="button"
>
<ChevronDownIcon />
</button>
</Tooltip>
{snapshot.numUnreadMessages > 0 && (
<div className={styles.jumpToBottomBadge}>{snapshot.numUnreadMessages}</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,57 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* 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.
*/
.root {
height: 100%;
width: 100%;
position: relative;
}
.scroller {
height: 100%;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
/* Turn off the browser's own scroll anchoring. It would try to hold the scroll position
when content changes, which conflicts with TanStack doing the same job in JS. */
overflow-anchor: none;
}
/* Applied during the first load. The list is laid out and scrolled into position, but kept
hidden (a spinner shows instead) so the user does not watch it shuffle into place. */
.hidden {
visibility: hidden;
}
.list {
width: 100%;
position: relative;
list-style: none;
margin: 0;
}
/* Rows are positioned absolutely, stacked by TanStack rather than by normal document flow.
TanStack writes each row's transform and the container's height straight to the DOM (the
directDomUpdates option), so never set transform or height here — it would be overwritten
or fight with what TanStack writes. */
.tile {
position: absolute;
top: 0;
left: 0;
width: 100%;
overflow-anchor: none;
list-style: none;
}
/* Holds the spinner shown in the middle of the panel while the timeline is still hidden. */
.cover {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
@@ -0,0 +1,152 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* 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 } from "react";
import { fn, expect, waitFor } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TimelineView } from "./TimelineView";
import type { TimelineItem, TimelineViewActions, TimelineViewSnapshot } from "./types";
import { useMockedViewModel } from "../../../core/viewmodel";
import { withViewDocs } from "../../../../.storybook/withViewDocs";
// A handful of deterministic, fixed-height message rows so the virtualizer lays
// out predictably for the visual snapshot (no avatars/media to decode).
const SENDERS = ["Alice", "Bob", "Carol"];
const LINES = [
"Morning! Did the deploy go out?",
"Yep, green across the board.",
"Nice. I'll close the ticket then.",
"One flaky test on CI, re-running.",
"Passed on the second go.",
"Great, merging now.",
];
const mockEvents = Array.from({ length: 12 }, (_, i) => ({
key: `evt-${i}`,
sender: SENDERS[i % SENDERS.length],
body: LINES[i % LINES.length],
}));
const mockContent = new Map(mockEvents.map((e) => [e.key, e]));
const mockItems: TimelineItem[] = mockEvents.map((e) => ({
key: e.key,
kind: "event",
continuation: false,
lastInSection: true,
}));
const renderItem = (item: TimelineItem): React.ReactNode => {
const content = mockContent.get(item.key);
if (!content) return null;
return (
<div style={{ padding: "6px 12px", minHeight: "44px", boxSizing: "border-box" }}>
<div style={{ fontWeight: 600, fontSize: "13px", color: "var(--cpd-color-text-primary)" }}>
{content.sender}
</div>
<div style={{ fontSize: "14px", color: "var(--cpd-color-text-primary)" }}>{content.body}</div>
</div>
);
};
type TimelineStoryProps = TimelineViewSnapshot & TimelineViewActions;
const TimelineViewWrapperImpl = ({
onStartReached,
onEndReached,
onAnchorReached,
onVisibleRangeChanged,
onAtBottomStateChange,
onJumpToReadMarker,
onMarkAllAsRead,
onJumpToLive,
...snapshot
}: TimelineStoryProps): JSX.Element => {
const vm = useMockedViewModel(snapshot, {
onStartReached,
onEndReached,
onAnchorReached,
onVisibleRangeChanged,
onAtBottomStateChange,
onJumpToReadMarker,
onMarkAllAsRead,
onJumpToLive,
});
return (
<div style={{ height: "400px", border: "1px solid #ccc" }}>
<TimelineView vm={vm} renderItem={renderItem} />
</div>
);
};
const TimelineViewWrapper = withViewDocs(TimelineViewWrapperImpl, TimelineView);
// The timeline lays out hidden behind a cover and reveals once the anchor settles
// (a couple of animation frames). Wait for that before the snapshot is captured.
const waitForReveal: NonNullable<StoryObj["play"]> = async ({ canvasElement }) => {
const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="timeline-scroller"]');
await waitFor(() => expect(scroller).toBeVisible());
};
const meta = {
title: "Timeline/TimelineView",
component: TimelineViewWrapper,
tags: ["autodocs"],
args: {
items: mockItems,
atLiveEnd: true,
pendingAnchor: null,
highlightedEventId: null,
isAtBottom: true,
canJumpToReadMarker: false,
numUnreadMessages: 0,
hasHighlights: false,
onStartReached: fn(),
onEndReached: fn(),
onAnchorReached: fn(),
onVisibleRangeChanged: fn(),
onAtBottomStateChange: fn(),
onJumpToReadMarker: fn(),
onMarkAllAsRead: fn(),
onJumpToLive: fn(),
},
decorators: [
(Story) => (
<div style={{ width: "420px" }}>
<Story />
</div>
),
],
play: waitForReveal,
// The virtualizer measures real DOM and reveals over a couple of frames; allow a
// little more pixel slack than the global default to absorb sub-pixel layout jitter.
parameters: {
snapshot: {
failureThreshold: 30,
},
},
} satisfies Meta<typeof TimelineViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A live timeline pinned to the bottom — no overlay controls. */
export const Default: Story = {};
/** Scrolled up off the live end, with unread messages: the jump-to-bottom badge shows. */
export const WithJumpToBottom: Story = {
args: {
atLiveEnd: false,
isAtBottom: false,
numUnreadMessages: 3,
},
};
/** A read marker above the viewport surfaces the unread bar. */
export const WithUnreadMarker: Story = {
args: {
canJumpToReadMarker: "above",
},
};
@@ -0,0 +1,169 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* 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 from "react";
import { act, render, screen, waitFor, type RenderResult } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { TimelineView } from "./TimelineView";
import type { TimelineItem, TimelineViewModel, TimelineViewSnapshot } from "./types";
const baseSnapshot: TimelineViewSnapshot = {
items: [],
atLiveEnd: true,
pendingAnchor: null,
highlightedEventId: null,
isAtBottom: true,
canJumpToReadMarker: false,
numUnreadMessages: 0,
hasHighlights: false,
};
function eventItems(count: number, offset = 0): TimelineItem[] {
return Array.from({ length: count }, (_, i) => ({
key: `evt-${offset + i}`,
kind: "event" as const,
continuation: false,
lastInSection: true,
}));
}
type Actions = {
onStartReached: ReturnType<typeof vi.fn>;
onEndReached: ReturnType<typeof vi.fn>;
onAnchorReached: ReturnType<typeof vi.fn>;
onVisibleRangeChanged: ReturnType<typeof vi.fn>;
onAtBottomStateChange: ReturnType<typeof vi.fn>;
onJumpToReadMarker: ReturnType<typeof vi.fn>;
onMarkAllAsRead: ReturnType<typeof vi.fn>;
onJumpToLive: ReturnType<typeof vi.fn>;
};
interface FakeVm {
vm: TimelineViewModel;
actions: Actions;
/** Push a new snapshot and notify subscribers (wrapped in act). */
update: (patch: Partial<TimelineViewSnapshot>) => void;
}
function makeFakeVm(initial: Partial<TimelineViewSnapshot> = {}): FakeVm {
let snapshot: TimelineViewSnapshot = { ...baseSnapshot, ...initial };
const listeners = new Set<() => void>();
const actions: Actions = {
onStartReached: vi.fn(),
onEndReached: vi.fn(),
onAnchorReached: vi.fn(),
onVisibleRangeChanged: vi.fn(),
onAtBottomStateChange: vi.fn(),
onJumpToReadMarker: vi.fn(),
onMarkAllAsRead: vi.fn(),
onJumpToLive: vi.fn(),
};
// vi.fn() carries a constructable signature that trips assignability to the
// ViewModel's action method types, so cast the assembled object.
const vm = {
getSnapshot: () => snapshot,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
...actions,
} as unknown as TimelineViewModel;
const update = (patch: Partial<TimelineViewSnapshot>): void => {
act(() => {
snapshot = { ...snapshot, ...patch };
listeners.forEach((l) => l());
});
};
return { vm, actions, update };
}
// Each row is a fixed 40px so the virtualizer measures a deterministic layout.
const ROW_HEIGHT = 40;
const renderItem = (item: TimelineItem): React.ReactNode => (
<div data-testid={`row-${item.key}`} style={{ height: ROW_HEIGHT }}>
{item.key}
</div>
);
// Fixed-height viewport: the TimelineView is height:100%, so its parent must size it.
const VIEWPORT_HEIGHT = 300;
function renderTimeline(vm: TimelineViewModel): RenderResult {
return render(
<div style={{ height: VIEWPORT_HEIGHT, width: 320 }}>
<TimelineView vm={vm} renderItem={renderItem} />
</div>,
);
}
describe("<TimelineView />", () => {
it("renders each item via the renderItem callback", async () => {
const { vm } = makeFakeVm({ items: eventItems(5) });
renderTimeline(vm);
expect(await screen.findByTestId("row-evt-0")).toBeInTheDocument();
expect(screen.getByTestId("row-evt-4")).toBeInTheDocument();
});
it("stays hidden behind the cover then reveals after the anchor settles", async () => {
// A list taller than the viewport so there is a real scroll offset to settle on.
const { vm, actions } = makeFakeVm({ items: eventItems(30) });
renderTimeline(vm);
const scroller = screen.getByTestId("timeline-scroller");
// Cover is up initially: the scroller is hidden and the anchor hasn't settled.
expect(scroller).toHaveStyle({ visibility: "hidden" });
expect(actions.onAnchorReached).not.toHaveBeenCalled();
await waitFor(() => expect(actions.onAnchorReached).toHaveBeenCalledTimes(1), { timeout: 5000 });
await waitFor(() => expect(scroller).toHaveStyle({ visibility: "visible" }));
});
it("reports the visible range and at-bottom state once live", async () => {
const { vm, actions } = makeFakeVm({ items: eventItems(30) });
renderTimeline(vm);
await waitFor(() => expect(actions.onAnchorReached).toHaveBeenCalled(), { timeout: 5000 });
await waitFor(() => expect(actions.onVisibleRangeChanged).toHaveBeenCalled());
await waitFor(() => expect(actions.onAtBottomStateChange).toHaveBeenCalled());
// Indices are 0-based into the items array.
const [start, end] = actions.onVisibleRangeChanged.mock.calls.at(-1)!;
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
});
it("re-renders when the view model pushes a new snapshot", async () => {
const { vm, update } = makeFakeVm({ items: eventItems(5) });
renderTimeline(vm);
await screen.findByTestId("row-evt-0");
update({ items: eventItems(6) });
expect(await screen.findByTestId("row-evt-5")).toBeInTheDocument();
});
it("shows the jump-to-bottom control once revealed when scrolled off the bottom", async () => {
const { vm, actions } = makeFakeVm({ items: eventItems(30), isAtBottom: false });
renderTimeline(vm);
const jumpToBottom = await screen.findByRole(
"button",
{ name: "Scroll to most recent messages" },
{ timeout: 5000 },
);
const user = userEvent.setup();
await user.click(jumpToBottom);
expect(actions.onJumpToLive).toHaveBeenCalledTimes(1);
// The View hands the VM its imperative scroll handle.
expect(actions.onJumpToLive.mock.calls[0][0]).toBeTypeOf("function");
});
});
@@ -0,0 +1,402 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* 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, { useCallback, useEffect, useLayoutEffect, useRef, useState, type JSX } from "react";
import classNames from "classnames";
import { useVirtualizer, type VirtualItem, type Virtualizer } from "@tanstack/react-virtual";
import { InlineSpinner } from "@vector-im/compound-web";
import { useViewModel } from "../../../core/viewmodel/useViewModel";
import type { AnchorAlign, ImmediateScroll, TimelineItem, TimelineViewProps } from "./types";
import { BACKWARD_LOADING_KEY, FORWARD_LOADING_KEY } from "./types";
import { TimelineOverlayButtons } from "./TimelineOverlayButtons";
import styles from "./TimelineView.module.css";
/**
* Renders the room timeline: a scrollable list of messages.
*
* The list is virtualised only the rows currently on screen (plus a few just outside it)
* exist in the DOM, so a room with thousands of messages stays fast. TanStack Virtual does
* that work; we drive it from `RoomTimelineViewModel`, which supplies the rows to show and
* is told in return what the user can see.
*
* The hard part of a chat timeline is holding the scroll position steady while the list
* changes underneath the reader. Rows appear at the top when older history loads, get
* removed when the loaded window is trimmed, and loading spinners come and go. Each of
* those shifts everything below it, which without care makes the message someone is
* reading jump away mid-sentence. How each case is handled:
*
* - **Older history arrives at the top.** `anchorTo: "end"` makes TanStack remember which
* row the user is looking at and, once the new rows are inserted above it, adjust the
* scroll position by the height that was added so that row stays exactly where it was
* on screen. It does this before the browser paints, so the shift is never visible.
*
* `isValidAnchorItem` stops it picking a loading spinner as that remembered row: the
* spinner is replaced by the messages it was waiting for, so afterwards there is no
* such row left to line up against and the timeline would lurch to the top instead.
*
* - **New messages arrive at the bottom.** `followOnAppend` scrolls down to keep them in
* view, but only when we are already at the live end and not jumping somewhere else.
*
* - **Reaching either end**, which is the cue to load more, is worked out from which rows
* are currently rendered. TanStack has no "you reached the top/bottom" callback.
*
* - **Jumping to a particular message** looks up how far down that message sits and
* scrolls straight to that position. We avoid TanStack's `scrollToIndex`, which keeps
* steering towards a row *number*: if history loads while it is doing that, every row
* shifts down and it follows the wrong one to the top. See `offsetForKey`.
*
* `directDomUpdates: true` lets TanStack position rows by writing to the DOM itself rather
* than going through a React render, so measuring a row and moving it happen in the same
* frame (splitting them across two caused a visible stutter). React still re-renders when
* the set of visible rows changes.
*
* Known gap: `overscan` counts rows rather than pixels, so how far it actually reaches
* beyond the viewport varies with how tall those rows happen to be.
*/
/** Seed height for not-yet-measured rows; kept near a typical chat row so the
* estimatemeasured correction stays small. TanStack caches real heights by key thereafter. */
const ESTIMATED_ITEM_HEIGHT = 48;
/** Rows rendered beyond the visible range each side — a COUNT, not px; ~16 ≈ a screenful. */
const OVERSCAN = 16;
/** px from the list bottom still counted as "at the bottom". */
const AT_BOTTOM_THRESHOLD_PX = 4;
/**
* How long we are willing to keep the timeline hidden on first load, in milliseconds.
*
* A spinner covers the list while it scrolls to the message it should start at, because that
* scroll is neither instant nor nice to watch: rows are still being measured, so the user
* would see blank white space where messages have not been placed yet, and would watch rows
* resize as their content finishes arriving URL previews appearing, polls decrypting. We
* drop the cover as soon as the target row settles at the position it was aiming for.
*
* That content can keep changing height for a while, which shifts the target underneath the
* scroll, so settling sometimes takes longer than expected and occasionally never quite
* completes, for instance when the requested alignment would need more content below it than
* the room has, and the browser clamps the scroll short of it. This is the point at which we
* stop waiting and show the timeline anyway: a slightly unsettled timeline beats an endless
* spinner.
*/
const REVEAL_TIMEOUT_MS = 1000;
/**
* How far the view has got through its first load:
* - "init" nothing rendered yet; waiting for the first batch of messages.
* - "placing" rows are laid out but still hidden while we scroll to the right spot.
* - "live" the timeline is visible and the user is in control of scrolling.
*/
type Phase = "init" | "placing" | "live";
export function TimelineView({ vm, renderItem }: TimelineViewProps): JSX.Element {
const snapshot = useViewModel(vm);
// The effects and callbacks below run outside React's render — from scroll events and
// animation frames — so they cannot use the `snapshot` variable captured when this
// function last ran, as it may be out of date by then. These refs always hold the
// latest values for them to read.
const snapshotRef = useRef(snapshot);
snapshotRef.current = snapshot;
// Loading spinners are ordinary entries in this list (kind: "loading") rather than
// something floating on top of it. That way each spinner occupies real space in the
// scroll area, so showing or hiding one is just another change to the list that the
// scroll anchoring described above already knows how to absorb.
const items = snapshot.items;
const itemsRef = useRef(items);
itemsRef.current = items;
const scrollerRef = useRef<HTMLDivElement | null>(null);
// On the first load we lay the rows out, scroll to the message we should start at, and
// only then show the result. Otherwise the user would watch the list shuffle around as
// rows are measured and the scroll position corrected. A spinner covers the gap. This
// happens once per room, as the panel is recreated when the room changes.
const [revealed, setRevealed] = useState(false);
const revealedRef = useRef(false);
// Gives each row a stable identity (its event id). TanStack uses these to recognise the
// same row from one update to the next, which is what makes the scroll anchoring
// possible at all. It also compares the first row's key between renders to spot when
// rows have been added or removed at the top, so this must always read the current list.
const getItemKey = useCallback((index: number): string => items[index]?.key ?? String(index), [items]);
// Refuses loading spinners as the row the scroll position is anchored to; see the note
// on `isValidAnchorItem` in the file comment above for why anchoring to one breaks.
//
// This tests the row's key rather than its position in the list. When rows are added or
// removed, TanStack is still working from the positions as they were before the change
// while `items` already reflects it, so looking a row up by position here would check
// the wrong one. The spinner keys are shared with the view model, in types.ts.
const isValidAnchorItem = useCallback(
(item: VirtualItem): boolean => item.key !== BACKWARD_LOADING_KEY && item.key !== FORWARD_LOADING_KEY,
[],
);
// ─── State used by the scroll reporting below ──────────────────────────────
const phaseRef = useRef<Phase>("init");
// We only want to tell the view model about things that have actually changed, so these
// hold the last values we sent and repeats are skipped.
const lastVisibleRangeRef = useRef<{ start: number; end: number } | null>(null);
const lastAtBottomRef = useRef<boolean | null>(null);
// For the "reached the top/bottom" reports we remember a short description of the
// situation we last reported, in the form "<number of rows>:<row index>", and clear it
// whenever we move away from that end. This stops us reporting over and over while
// sitting still at the end, while still reporting again once more history has loaded
// and the user scrolls further into it.
const startEdgeTokenRef = useRef("");
const endEdgeTokenRef = useRef("");
// Tells the view model what the user can currently see: which rows are on screen,
// whether we are at the bottom, and whether either end of the loaded messages has been
// reached (the cue for it to load more). TanStack calls this whenever it updates —
// on scroll, after measuring a row, or when the set of visible rows changes.
//
// This only reads state, it never scrolls. It stays quiet until the first load has
// finished and while we are scrolling to a message the view model asked for, because
// until then the scroll position reflects our own automatic placement rather than
// anything the user did.
const reportVisibleState = useCallback(
(v: Virtualizer<HTMLDivElement, Element>): void => {
if (phaseRef.current !== "live" || snapshotRef.current.pendingAnchor !== null) return;
const itemCount = itemsRef.current.length;
const visibleRange = v.range;
// Which rows are on screen, given as positions in the items array.
if (
visibleRange &&
(lastVisibleRangeRef.current?.start !== visibleRange.startIndex ||
lastVisibleRangeRef.current?.end !== visibleRange.endIndex)
) {
lastVisibleRangeRef.current = { start: visibleRange.startIndex, end: visibleRange.endIndex };
vm.onVisibleRangeChanged(visibleRange.startIndex, visibleRange.endIndex);
}
// Are we scrolled to the bottom? Worked out from figures TanStack already holds
// (how far we have scrolled, the viewport height, the total height) rather than
// measuring the DOM, which would force the browser to redo layout on every call.
const scrollOffset = v.scrollOffset ?? 0;
const viewportHeight = v.scrollRect?.height ?? 0;
const totalSize = v.getTotalSize();
const atBottom = viewportHeight > 0 && scrollOffset + viewportHeight >= totalSize - AT_BOTTOM_THRESHOLD_PX;
if (atBottom !== lastAtBottomRef.current) {
lastAtBottomRef.current = atBottom;
vm.onAtBottomStateChange(atBottom);
}
// Have we reached either end of the loaded messages? True once the very first or
// very last row is among those being rendered, which tells the view model it may
// need to load more history in that direction.
const renderedItems = v.getVirtualItems();
const firstRenderedIndex = renderedItems.length ? renderedItems[0].index : -1;
const lastRenderedIndex = renderedItems.length ? renderedItems[renderedItems.length - 1].index : -1;
if (firstRenderedIndex === 0) {
const token = `${itemCount}:${visibleRange ? visibleRange.startIndex : 0}`;
if (startEdgeTokenRef.current !== token) {
startEdgeTokenRef.current = token;
vm.onStartReached();
}
} else {
startEdgeTokenRef.current = "";
}
if (itemCount > 0 && lastRenderedIndex === itemCount - 1) {
const token = `${itemCount}:${visibleRange ? visibleRange.endIndex : 0}`;
if (endEdgeTokenRef.current !== token) {
endEdgeTokenRef.current = token;
vm.onEndReached();
}
} else {
endEdgeTokenRef.current = "";
}
},
[vm],
);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollerRef.current,
// Only consulted for rows that have not been measured yet. TanStack measures each
// row as it renders and remembers the result by key, reusing it as rows are added,
// trimmed or reloaded, so we do not need a size cache of our own.
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
getItemKey,
overscan: OVERSCAN,
// Keep whatever the user is looking at visually still when rows are added or
// removed, correcting the scroll position before the browser paints. This is the
// main thing stopping the timeline jumping; see the file comment for how it works.
anchorTo: "end",
// ...but never hold onto a loading spinner as that reference row (see above). This
// option comes from our @tanstack/virtual-core patch and is pending upstream.
isValidAnchorItem,
// Scroll down to follow newly arrived messages, but only when we are at the live end
// of the timeline and are not part-way through jumping somewhere else.
followOnAppend: snapshot.atLiveEnd && snapshot.pendingAnchor === null,
// Let TanStack place rows by writing to the DOM directly instead of re-rendering.
// Because of this, never set transform or height on a row in JSX below — it would
// fight with what TanStack writes.
directDomUpdates: true,
// Called on every TanStack update; we use it to report what is on screen upwards.
onChange: reportVisibleState,
});
// Works out how far down the list we would have to scroll, in pixels, to bring the row
// with `targetKey` into view — `align` saying whether it should end up at the top,
// the middle or the bottom of the viewport. Returns null if that message is not among
// the ones currently loaded, in which case the caller cannot scroll to it yet.
//
// Callers hand the result to `scrollToOffset`, which simply scrolls to a fixed pixel
// position. We deliberately do not use `scrollToIndex`: that keeps steering towards a
// row *number* as rows are measured, so if older history loads while it is still
// adjusting, every row shifts down and it follows the wrong one up to the top.
const offsetForKey = useCallback(
(targetKey: string | null, align: AnchorAlign): number | null => {
const idx = targetKey ? itemsRef.current.findIndex((i) => i.key === targetKey) : -1;
if (idx < 0) return null;
const info = virtualizer.getOffsetForIndex(idx, align);
return info ? info[0] : null;
},
[virtualizer],
);
// ─── First load: scroll to the starting message while hidden, then reveal ──
// Runs once, as soon as the first batch of messages arrives.
// Holds the pending animation frame from the settle loop below, so it can be cancelled
// if the panel goes away while that loop is still running — switching room part-way
// through the first load, for example. Without this the callback would carry on and
// update state on a component that no longer exists, and call a disposed view model.
const coldRafRef = useRef<number | undefined>(undefined);
useEffect(() => {
return () => {
if (coldRafRef.current !== undefined) cancelAnimationFrame(coldRafRef.current);
};
}, []);
useLayoutEffect(() => {
if (phaseRef.current !== "init" || items.length === 0) return;
// Move out of "init" immediately, so that if more messages arrive while we are
// still placing this effect runs again but returns here rather than starting over.
phaseRef.current = "placing";
// Start at the message the view model asked for. If it did not ask for one, or that
// message is not in the batch we were given, start at the newest message instead.
const anchor = snapshotRef.current.pendingAnchor;
const list = itemsRef.current;
let idx = anchor ? list.findIndex((i) => i.key === anchor.targetKey) : -1;
if (idx < 0) idx = list.length - 1;
const align: AnchorAlign = anchor?.align ?? "end";
// Let TanStack carry out this scroll: it keeps correcting the target as rows are
// measured and their real heights become known. We must not set the scroll position
// ourselves as well — two things moving the viewport at once end up fighting.
if (idx >= 0) virtualizer.scrollToIndex(idx, { align, behavior: "auto" });
// Now watch each frame until that row actually reaches the position it was heading
// for, and reveal the timeline once it has. requestAnimationFrame passes the frame's
// timestamp, so we can measure how long we have been waiting in real time and give up
let startedAt: number | undefined;
const tick = (now: number): void => {
startedAt ??= now;
const info = virtualizer.getOffsetForIndex(idx, align);
const offset = virtualizer.scrollOffset ?? 0;
const landed = info !== undefined && Math.abs(info[0] - offset) <= 1.5;
if (landed || now - startedAt >= REVEAL_TIMEOUT_MS) {
phaseRef.current = "live";
if (!revealedRef.current) {
revealedRef.current = true;
setRevealed(true);
}
vm.onAnchorReached();
return;
}
coldRafRef.current = requestAnimationFrame(tick);
};
coldRafRef.current = requestAnimationFrame(tick);
}, [items.length, virtualizer, vm]);
// ─── Later jumps: scroll to a message the view model has asked for ─────────
// Once the first load is done, the view model can ask us to jump somewhere by setting
// `pendingAnchor`. This is used by "jump to the latest message" and "jump to the first
// unread message" when the target was not already loaded, so it had to be fetched and
// the timeline rebuilt around it first.
//
// This effect runs after every render, so it acts as soon as those messages appear, and
// it scrolls before the browser paints so the jump is never seen as a scrolling motion.
// `lastPlacedAnchorKeyRef` records which message we last jumped to, so that later
// renders do not repeat the same jump and fight the user's own scrolling.
const lastPlacedAnchorKeyRef = useRef<string | null>(null);
useLayoutEffect(() => {
if (phaseRef.current !== "live") return;
const anchor = snapshotRef.current.pendingAnchor;
if (!anchor) {
lastPlacedAnchorKeyRef.current = null;
return;
}
if (lastPlacedAnchorKeyRef.current !== anchor.targetKey) {
const target = offsetForKey(anchor.targetKey, anchor.align);
if (target !== null) {
virtualizer.scrollToOffset(target);
lastPlacedAnchorKeyRef.current = anchor.targetKey;
vm.onAnchorReached();
}
}
});
// Handed to the overlay buttons, and through them to the view model, so it can scroll
// us straight away when the message it wants is already loaded — no fetch needed, and
// no round trip through `pendingAnchor` above.
const scrollNow = useCallback<ImmediateScroll>(
(anchor) => {
const target = offsetForKey(anchor.targetKey, anchor.align);
if (target !== null) virtualizer.scrollToOffset(target);
},
[offsetForKey, virtualizer],
);
const virtualItems = virtualizer.getVirtualItems();
return (
<div className={styles.root}>
<div
ref={scrollerRef}
data-testid="timeline-scroller"
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
className={classNames(styles.scroller, { [styles.hidden]: !revealed })}
>
{/* An <ol> of <li> rows, so screen readers announce this as a list of messages
and can say how many there are. The role="list" is stated explicitly even
though an <ol> already is one: Safari with VoiceOver stops treating a list
as a list once list-style is set to none, which our CSS does. The old
ScrollPanel does the same thing for the same reason. */}
{/* eslint-disable jsx-a11y/no-redundant-roles -- see comment above */}
<ol
ref={virtualizer.containerRef}
className={classNames("mx_TimelineView_list", styles.list)}
role="list"
>
{/* eslint-enable jsx-a11y/no-redundant-roles */}
{virtualItems.map((vi) => {
const item: TimelineItem | undefined = items[vi.index];
if (!item) return null;
return (
<li
key={vi.key}
className={classNames("mx_TimelineView_tile", styles.tile)}
data-index={vi.index}
data-key={item.key}
ref={virtualizer.measureElement}
>
{renderItem(item)}
</li>
);
})}
</ol>
</div>
{!revealed && (
<div className={styles.cover}>
<InlineSpinner size={32} />
</div>
)}
{revealed && <TimelineOverlayButtons snapshot={snapshot} vm={vm} scrollNow={scrollNow} />}
</div>
);
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* 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.
*/
export { TimelineView } from "./TimelineView";
export { BACKWARD_LOADING_KEY, FORWARD_LOADING_KEY } from "./types";
export type {
TimelineItem,
TimelineItemKind,
TimelineViewSnapshot,
TimelineViewActions,
TimelineViewModel,
TimelineViewProps,
NavigationAnchor,
AnchorAlign,
ImmediateScroll,
} from "./types";
@@ -0,0 +1,201 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* 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 type { ReactNode } from "react";
import type { ViewModel } from "../../../core/viewmodel/ViewModel";
// ─── Timeline item: one renderable row ─────────────────────────────
/** Discriminated union of every row kind the timeline can render. */
export type TimelineItemKind = "event" | "date-separator" | "read-marker" | "loading" | "gap";
export interface EventTimelineItem {
key: string;
kind: "event";
/** Whether this event continues unbroken from the previous sender (suppresses avatar/name). */
continuation: boolean;
/**
* Whether this event closes its continuation group (next event has a different
* sender / a gap / a separator, or it is the last). Rounds the group's closing
* corner border-radius only, so it is recomputed every build, never cached.
*/
lastInSection: boolean;
}
export interface DateSeparatorTimelineItem {
key: string;
kind: "date-separator";
label: string;
}
export interface ReadMarkerTimelineItem {
key: string;
kind: "read-marker";
}
export interface LoadingTimelineItem {
key: string;
kind: "loading";
}
export interface GapTimelineItem {
key: string;
kind: "gap";
}
export type TimelineItem =
| EventTimelineItem
| DateSeparatorTimelineItem
| ReadMarkerTimelineItem
| LoadingTimelineItem
| GapTimelineItem;
/**
* Stable keys for the edge loading spinners. The ViewModel assigns one of these as the
* `key` of a `kind:"loading"` item; the View matches them to keep scroll anchoring off a
* spinner (whose key vanishes when the loaded batch replaces it). Shared here so the two
* sides can't drift.
*/
export const BACKWARD_LOADING_KEY = "backward-loading";
export const FORWARD_LOADING_KEY = "forward-loading";
// ─── Navigation anchor ─────────────────────────────────────────────
/** Where in the viewport to place the target when scrolling to an anchor. */
export type AnchorAlign = "start" | "center" | "end";
export interface NavigationAnchor {
/** The `TimelineItem.key` to scroll to. */
targetKey: string;
/** Where in the viewport to place the target. */
align: AnchorAlign;
}
/**
* Imperative scroll-to-anchor handle the View hands to ViewModel actions, to scroll
* without waiting for a data update. The VM invokes it only when the target is already
* in the loaded window; otherwise it sets `pendingAnchor` and lets a load() drive the
* scroll. Must be called synchronously inside the action the View's closure captures
* the current items snapshot to resolve the target.
*/
export type ImmediateScroll = (anchor: NavigationAnchor) => void;
// ─── Timeline view model contract ──────────────────────────────────
export interface TimelineViewSnapshot {
/** The ordered list of items to render. */
items: TimelineItem[];
/**
* True when the timeline window has reached the live end i.e. there are
* no more forward events to paginate to. Used to gate follow-on-append so
* that the view only auto-scrolls to the bottom when we are actually
* viewing the live end of the room.
*/
atLiveEnd: boolean;
/**
* Placement target for the current load. The View scrolls it into place on mount
* and re-asserts it on later loads without remounting. While set, follow-on-append
* is disabled (this also keeps a cold-loading list pinned to the anchor rather than
* snapping to the bottom). Cleared when the View reports {@link TimelineViewActions.onAnchorReached}.
*/
pendingAnchor: NavigationAnchor | null;
/**
* The event ID that should be visually highlighted (e.g. permalink target).
* Unlike `pendingAnchor`, this is not cleared after scrolling it persists
* so the event tile stays highlighted.
*/
highlightedEventId: string | null;
/** True when the list is scrolled to the bottom (within a 4px threshold). */
isAtBottom: boolean;
/**
* Whether a read-marker is visible above (`"above"`) or below (`"below"`) the
* current viewport, or not reachable/applicable (`false`).
* - `"above"` marker is above the viewport (or above the loaded window).
* - `"below"` marker is below the viewport but within the loaded window.
* Controls visibility and direction of the "Jump to unread" / "Mark as read" bar.
*/
canJumpToReadMarker: "above" | "below" | false;
/**
* Number of new messages that have arrived since the user last scrolled
* to the live bottom. Reset to zero when the user reaches the live bottom.
* Used as the badge count on the "Jump to bottom" button.
*/
numUnreadMessages: number;
/**
* True when at least one of the new-since-leaving-bottom messages is a
* highlight (mention / keyword). Drives the highlight style on the
* "Jump to bottom" button.
*/
hasHighlights: boolean;
}
export interface TimelineViewActions {
/** Called when the view reaches the start; VM decides whether to paginate. */
onStartReached(): void;
/** Called when the view reaches the end; VM decides whether to paginate. */
onEndReached(): void;
/**
* Report that the anchor placement has settled (the target has stabilised in
* the visible range). The VM clears `pendingAnchor`, re-enabling
* follow-on-append and normal scroll-position tracking.
*/
onAnchorReached(): void;
/**
* Called on every visible-range change; the VM tracks the bottommost visible event
* for scroll-position persistence. Indices are 0-based into the items array.
*/
onVisibleRangeChanged(startIndex: number, endIndex: number): void;
/** Called when the at-bottom state changes; VM uses this to decide whether to clear the saved scroll position on dispose. */
onAtBottomStateChange(atBottom: boolean): void;
/**
* Scroll to the read-marker item (jump to unread messages).
*
* `scrollNow` is invoked synchronously when the marker is already in the
* loaded window (no data update needed). Otherwise the VM triggers a load
* at the marker and the scroll happens via `pendingAnchor` after the load.
*/
onJumpToReadMarker(scrollNow: ImmediateScroll): void;
/** Mark all currently-visible messages as read, clearing the read marker. */
onMarkAllAsRead(): void;
/**
* Navigate to the live end of the timeline.
*
* `scrollNow` is invoked synchronously when the window already reaches
* the live end (no data update needed). Otherwise the VM reloads the
* timeline window at the live end and the scroll happens via
* `pendingAnchor` after the load.
*/
onJumpToLive(scrollNow: ImmediateScroll): void;
}
export type TimelineViewModel = ViewModel<TimelineViewSnapshot, TimelineViewActions>;
// ─── Shared timeline view props ────────────────────────────────────
export interface TimelineViewProps {
vm: TimelineViewModel;
/**
* Render callback for each timeline item.
* The shared container calls this for every visible item.
*/
renderItem: (item: TimelineItem) => ReactNode;
}
@@ -0,0 +1,47 @@
diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
index 387489bd25c9ebcf6afd97df5e741706fbdec65d..0e9961bb69b411824ba91f56694774e801d599c4 100644
--- a/dist/esm/index.d.ts
+++ b/dist/esm/index.d.ts
@@ -78,6 +78,12 @@ export interface VirtualizerOptions<TScrollElement extends Element | Window, TIt
initialMeasurementsCache?: Array<VirtualItem>;
lanes?: number;
anchorTo?: ScrollAnchor;
+ /**
+ * With `anchorTo: "end"`, reject an item as the scroll anchor (e.g. a transient
+ * placeholder/sentinel row whose key does not survive the update); anchoring
+ * steps inward to the nearest accepted item. Element-web patch, pending upstream.
+ */
+ isValidAnchorItem?: (item: VirtualItem) => boolean;
followOnAppend?: FollowOnAppend;
scrollEndThreshold?: number;
isScrollingResetDelay?: number;
diff --git a/dist/esm/index.js b/dist/esm/index.js
index 00fd4a4b0a0049193f8862c2604cb76013d605d5..37c058b25d30f42f82617d9cc4c2831bc0b13704 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -295,7 +295,24 @@ class Virtualizer {
const didEdgeKeysChange = didCountChange || prevCount > 0 && nextCount > 0 && (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey);
if (didEdgeKeysChange) {
edgeKeysChanged = true;
- const item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null;
+ let item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null;
+ // With anchorTo: "end" the scroll is re-pinned by the key of the item
+ // under the current offset. If the consumer rejects that item as an
+ // anchor via isValidAnchorItem — e.g. a transient placeholder/sentinel
+ // row (loading spinner, "load more", skeleton) whose key does not
+ // survive the update — re-pinning by its key would fail and the
+ // prepend would go uncompensated (the list jumps to the edge). Such
+ // rows only sit at the extreme edges, so step one index inward to the
+ // nearest accepted item, whose key persists across the update.
+ const isValidAnchorItem = merged.isValidAnchorItem;
+ if (item && isValidAnchorItem && !isValidAnchorItem(item)) {
+ const step = item.index === 0 ? 1 : -1;
+ let i = item.index;
+ while (i >= 0 && i < measurements.length && measurements[i] && !isValidAnchorItem(measurements[i])) {
+ i += step;
+ }
+ item = measurements[i] ?? item;
+ }
if (item) {
anchor = [item.key, this.getScrollOffset() - item.start];
}
+21
View File
@@ -311,6 +311,7 @@ packageExtensionsChecksum: sha256-kCJccFy7l6HmjcpzmxV958fHndI9zZq246f1ttgkURI=
patchedDependencies:
'@arcmantle/vite-plugin-import-css-sheet': 8019aa9feca17db6bab3483612b4150c911d70b58fddc52bf2d7258a1484a747
'@matrix-org/react-sdk-module-api': 016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96
'@tanstack/virtual-core@3.17.6': 9db9d80c4fe7a7a9911643522aef9bc9359075570c38f3d847ff75ed635d921c
'@types/auto-launch': b60dc9846a11a1684ce52c55493f29ea398d3dc44b88f66ab44e9843d6c01538
'@vector-im/matrix-wysiwyg': 7bdf6150f2905bc2f055a6bcaa7b9d78fa7ffde82e800bcc454ac7b0096bd65e
await-lock: b767a571946a4f8710ac54b1a7bec8a7c1570f9b85d75922392fae20d0578964
@@ -1384,6 +1385,9 @@ importers:
'@matrix-org/spec':
specifier: ^1.7.0
version: 1.16.0
'@tanstack/react-virtual':
specifier: 3.14.8
version: 3.14.8(react-dom@19.2.8)(react@19.2.8)
'@vector-im/compound-design-tokens':
specifier: 'catalog:'
version: 10.2.1(@types/react@19.2.18)(react@19.2.8)
@@ -5826,6 +5830,15 @@ packages:
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
'@tanstack/react-virtual@3.14.8':
resolution: {integrity: sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.17.6':
resolution: {integrity: sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==}
'@testcontainers/postgresql@12.0.4':
resolution: {integrity: sha512-a/pLU6j5lpKKAlUTPwqweqMGhOSjgTSb6HBX69TOrXn32ifU37nnQDmNFTj8ddOAw+BQL9oTRkeOxVbZkqhgZA==}
@@ -18405,6 +18418,14 @@ snapshots:
dependencies:
defer-to-connect: 2.0.1
'@tanstack/react-virtual@3.14.8(react-dom@19.2.8)(react@19.2.8)':
dependencies:
'@tanstack/virtual-core': 3.17.6(patch_hash=9db9d80c4fe7a7a9911643522aef9bc9359075570c38f3d847ff75ed635d921c)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
'@tanstack/virtual-core@3.17.6(patch_hash=9db9d80c4fe7a7a9911643522aef9bc9359075570c38f3d847ff75ed635d921c)': {}
'@testcontainers/postgresql@12.0.4(supports-color@10.2.2)':
dependencies:
testcontainers: 12.0.4(supports-color@10.2.2)
+6
View File
@@ -119,6 +119,8 @@ patchedDependencies:
plist: patches/plist.patch
# Workaround for type fails
"@arcmantle/vite-plugin-import-css-sheet": patches/@arcmantle__vite-plugin-import-css-sheet.patch
# Workaround for scroll anchoring landing on transient placeholder rows (pending upstream)
"@tanstack/virtual-core@3.17.6": patches/@tanstack__virtual-core@3.17.6.patch
peerDependencyRules:
allowedVersions:
@@ -214,3 +216,7 @@ minimumReleaseAgeExclude:
# Temporary for testing purposes
- "@typescript/*"
- typescript@7.0.2
# New timeline: 3.14.8/3.17.6 carry the upstream end-anchored-prepend fix
# (#1237); they were freshly published so are still inside the release-age
# window. Revisit once the release has aged past the cutoff.
- "@tanstack/*"