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.
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -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",
|
||||
|
||||
@@ -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
|
||||
* estimate→measured 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];
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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/*"
|
||||
|
||||