diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/jump-to-bottom-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/jump-to-bottom-auto.png new file mode 100644 index 0000000000..759ad99002 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/jump-to-bottom-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/jump-to-bottom-with-highlight-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/jump-to-bottom-with-highlight-auto.png new file mode 100644 index 0000000000..39fed9fc25 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/jump-to-bottom-with-highlight-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/jump-to-bottom-with-unread-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/jump-to-bottom-with-unread-auto.png new file mode 100644 index 0000000000..48be5c1bd1 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/jump-to-bottom-with-unread-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/unread-marker-above-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/unread-marker-above-auto.png new file mode 100644 index 0000000000..43329bd666 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/unread-marker-above-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/unread-marker-below-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/unread-marker-below-auto.png new file mode 100644 index 0000000000..7d12fe4b5e Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx/unread-marker-below-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..10224ea1ef Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineView.stories.tsx/with-jump-to-bottom-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineView.stories.tsx/with-jump-to-bottom-auto.png new file mode 100644 index 0000000000..8d9f3484b0 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineView.stories.tsx/with-jump-to-bottom-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineView.stories.tsx/with-unread-marker-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineView.stories.tsx/with-unread-marker-auto.png new file mode 100644 index 0000000000..0f78383ddf Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/TimelineView/TimelineView.stories.tsx/with-unread-marker-auto.png differ diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json index 414e1a5802..5a906f28ac 100644 --- a/packages/shared-components/package.json +++ b/packages/shared-components/package.json @@ -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", diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index d93dfba100..a661272f26 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -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", diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 194f471630..2abd8c1bcc 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/room/timeline/TimelineView/.gitkeep b/packages/shared-components/src/room/timeline/TimelineView/.gitkeep deleted file mode 100644 index 8b13789179..0000000000 --- a/packages/shared-components/src/room/timeline/TimelineView/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/shared-components/src/room/timeline/TimelineView/TimelineOverlayButtons.module.css b/packages/shared-components/src/room/timeline/TimelineView/TimelineOverlayButtons.module.css new file mode 100644 index 0000000000..cf3b19ba26 --- /dev/null +++ b/packages/shared-components/src/room/timeline/TimelineView/TimelineOverlayButtons.module.css @@ -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); +} diff --git a/packages/shared-components/src/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx b/packages/shared-components/src/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx new file mode 100644 index 0000000000..daf46a51b4 --- /dev/null +++ b/packages/shared-components/src/room/timeline/TimelineView/TimelineOverlayButtons.stories.tsx @@ -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 ; +}; + +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) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** 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" }, +}; diff --git a/packages/shared-components/src/room/timeline/TimelineView/TimelineOverlayButtons.test.tsx b/packages/shared-components/src/room/timeline/TimelineView/TimelineOverlayButtons.test.tsx new file mode 100644 index 0000000000..4496f5fb24 --- /dev/null +++ b/packages/shared-components/src/room/timeline/TimelineView/TimelineOverlayButtons.test.tsx @@ -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("", () => { + it("renders no buttons at the live bottom with no read marker", () => { + const actions = makeActions(); + render(); + + 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( + , + ); + expect(button(JUMP_TO_BOTTOM)).toBeInTheDocument(); + }); + + it("shows when at the live end but scrolled up off the bottom", () => { + render( + , + ); + 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( + , + ); + + await user.click(button(JUMP_TO_BOTTOM)); + + expect(actions.onJumpToLive).toHaveBeenCalledWith(scrollNow); + }); + + it("renders the unread badge count", () => { + render( + , + ); + expect(screen.getByText("7")).toBeInTheDocument(); + }); + + it("omits the badge when there are no unread messages", () => { + render( + , + ); + expect(screen.queryByText("0")).toBeNull(); + }); + + it("applies the highlight style when there are highlight messages", () => { + const { container } = render( + , + ); + 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( + , + ); + expect(button(JUMP_READ_MARKER)).toBeInTheDocument(); + expect(button(MARK_ALL_READ)).toBeInTheDocument(); + }); + + it("shows the bar when the marker is below the viewport", () => { + render( + , + ); + 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( + , + ); + + await user.click(button(JUMP_READ_MARKER)); + expect(actions.onJumpToReadMarker).toHaveBeenCalledWith(scrollNow); + + await user.click(button(MARK_ALL_READ)); + expect(actions.onMarkAllAsRead).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/shared-components/src/room/timeline/TimelineView/TimelineOverlayButtons.tsx b/packages/shared-components/src/room/timeline/TimelineView/TimelineOverlayButtons.tsx new file mode 100644 index 0000000000..33ba9fd047 --- /dev/null +++ b/packages/shared-components/src/room/timeline/TimelineView/TimelineOverlayButtons.tsx @@ -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. +
+ {/* Top unread bar — marker is above the viewport */} + {readMarkerDirection === "above" && ( +
+ + + + + + +
+ )} + + {/* Unread bar — marker is below the viewport; sits at top-right like the above bar */} + {readMarkerDirection === "below" && ( +
+ + + + + + +
+ )} + + {/* Jump-to-bottom button — matches legacy JumpToBottomButton */} + {showJumpToBottom && ( +
+ + + + {snapshot.numUnreadMessages > 0 && ( +
{snapshot.numUnreadMessages}
+ )} +
+ )} +
+ ); +} diff --git a/packages/shared-components/src/room/timeline/TimelineView/TimelineView.module.css b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.module.css new file mode 100644 index 0000000000..1b4dd5829f --- /dev/null +++ b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.module.css @@ -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; +} diff --git a/packages/shared-components/src/room/timeline/TimelineView/TimelineView.stories.tsx b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.stories.tsx new file mode 100644 index 0000000000..0331cdc977 --- /dev/null +++ b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.stories.tsx @@ -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 ( +
+
+ {content.sender} +
+
{content.body}
+
+ ); +}; + +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 ( +
+ +
+ ); +}; +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 = async ({ canvasElement }) => { + const scroller = canvasElement.querySelector('[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) => ( +
+ +
+ ), + ], + 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; + +export default meta; +type Story = StoryObj; + +/** 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", + }, +}; diff --git a/packages/shared-components/src/room/timeline/TimelineView/TimelineView.test.tsx b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.test.tsx new file mode 100644 index 0000000000..a0f6f7dc94 --- /dev/null +++ b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.test.tsx @@ -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; + onEndReached: ReturnType; + onAnchorReached: ReturnType; + onVisibleRangeChanged: ReturnType; + onAtBottomStateChange: ReturnType; + onJumpToReadMarker: ReturnType; + onMarkAllAsRead: ReturnType; + onJumpToLive: ReturnType; +}; + +interface FakeVm { + vm: TimelineViewModel; + actions: Actions; + /** Push a new snapshot and notify subscribers (wrapped in act). */ + update: (patch: Partial) => void; +} + +function makeFakeVm(initial: Partial = {}): 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): 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 => ( +
+ {item.key} +
+); + +// 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( +
+ +
, + ); +} + +describe("", () => { + 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"); + }); +}); diff --git a/packages/shared-components/src/room/timeline/TimelineView/TimelineView.tsx b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.tsx new file mode 100644 index 0000000000..30a5936ee7 --- /dev/null +++ b/packages/shared-components/src/room/timeline/TimelineView/TimelineView.tsx @@ -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(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("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(null); + // For the "reached the top/bottom" reports we remember a short description of the + // situation we last reported, in the form ":", 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): 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(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(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( + (anchor) => { + const target = offsetForKey(anchor.targetKey, anchor.align); + if (target !== null) virtualizer.scrollToOffset(target); + }, + [offsetForKey, virtualizer], + ); + + const virtualItems = virtualizer.getVirtualItems(); + + return ( +
+
+ {/* An
    of
  1. 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
      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 */} +
        + {/* eslint-enable jsx-a11y/no-redundant-roles */} + {virtualItems.map((vi) => { + const item: TimelineItem | undefined = items[vi.index]; + if (!item) return null; + return ( +
      1. + {renderItem(item)} +
      2. + ); + })} +
      +
+ {!revealed && ( +
+ +
+ )} + {revealed && } +
+ ); +} diff --git a/packages/shared-components/src/room/timeline/TimelineView/index.ts b/packages/shared-components/src/room/timeline/TimelineView/index.ts new file mode 100644 index 0000000000..c005db20ce --- /dev/null +++ b/packages/shared-components/src/room/timeline/TimelineView/index.ts @@ -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"; diff --git a/packages/shared-components/src/room/timeline/TimelineView/types.ts b/packages/shared-components/src/room/timeline/TimelineView/types.ts new file mode 100644 index 0000000000..6a4b54989d --- /dev/null +++ b/packages/shared-components/src/room/timeline/TimelineView/types.ts @@ -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; + +// ─── 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; +} diff --git a/patches/@tanstack__virtual-core@3.17.6.patch b/patches/@tanstack__virtual-core@3.17.6.patch new file mode 100644 index 0000000000..2604e074b5 --- /dev/null +++ b/patches/@tanstack__virtual-core@3.17.6.patch @@ -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; + 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]; + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1d8f0b4e1..788f4815dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3d4b5bb72c..f82efcbaef 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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/*"