Add unread toast to room list sections (#33961)

* Forward the scroller element and scroll handle through VirtualizedList

The room list needs two things the generic list did not expose: the underlying
scroll container (to observe which items are genuinely visible) and the imperative
scroll handle (to scroll an item into view). Forward an optional scrollerRef from
useVirtualizedList and pass scrollHandleRef through FlatVirtualizedList, mirroring
what GroupedVirtualizedList already exposes. Both are additive and optional, so other
consumers are unaffected.

Signed-off-by: David Langley <langley.dave@gmail.com>

* Add an unread-activity toast to the room list

Show a clickable "You have unread activity" pill when there are unread rooms
scrolled below the visible fold, including unreads hidden inside collapsed sections
whose header is below the fold. Clicking it scrolls the next such unread into view.

The visible fold is tracked with an IntersectionObserver over the rendered item
elements, so the toast appears as soon as a room crosses the fold rather than only
once it leaves Virtuoso's overscan buffer. The toast click is wired through an
imperative scroll handle the view registers with the view model.

Signed-off-by: David Langley <langley.dave@gmail.com>

* Change toast to target notifications only, not unread activity.

* Fix formatting and refresh Toast snapshots for compound-web 9.7.0

- Apply oxfmt to the room list view model and virtualized view.
- Refresh the RoomListToast/RoomListView story snapshots: compound-web
  9.7.0 moves the typography classes onto the toast .content element and
  rebuilds the Toast CSS module hashes.
- Add the missing UnreadActivityToast render snapshot baseline.

* Add e2e tests for the room list unread activity toast

Cover the unread-activity toast end to end:
- it appears for a notifying room scrolled below the fold and clicking it
  scrolls that room into view (then the toast clears);
- a room with only an unread-activity dot (no notification count) does not
  raise it;
- a collapsed section hiding a notifying room raises it, and clicking
  scrolls the section header into view.

* Add visual snapshot baselines for the unread-activity toast stories

* Converge room-list toast codepaths into a single state-driven toast

Reviewer feedback: the unread-activity toast and the transient event toasts
(section_created / chat_moved) should share one mechanism, with toast
lifecycle and precedence owned by the view model rather than split across a
separate snapshot flag and a view-level ternary.

- RoomListViewModel now reconciles the transient event toast and the derived
  unread-activity state into a single snapshot.toast via recomputeToast(); the
  event toast still takes precedence and auto-dismisses, and the unread toast
  reappears once it clears. Drops hasUnreadActivityBelow from the snapshot.
- RoomListView renders a single RoomListToast driven by snapshot.toast; the
  ToastType union gains 'unread_activity'. The standalone UnreadActivityToast
  component is folded into RoomListToast (clickable arrow-down variant).
- Adds VM tests for the unread-activity toast and the event/unread precedence.

* Pre-bundle the room-list dnd-kit + virtuoso graph in vitest browser mode

The room-list suites (RoomListView, VirtualizedRoomListView,
RoomListItemMoreOptionsMenu) import a heavy @dnd-kit + react-virtuoso graph.
Only @dnd-kit/abstract was pinned in optimizeDeps.include; @dnd-kit/dom,
@dnd-kit/react, @dnd-kit/abstract/modifiers and react-virtuoso were left to
runtime discovery. Under CI load the browser-mode dep optimizer can discover
them late, re-bundle and reload the page, which fails the in-flight
setupTests.ts import for those suites. Pinning the whole graph forces it into
the initial optimize pass so no re-run happens while tests load.

---------

Signed-off-by: David Langley <langley.dave@gmail.com>
This commit is contained in:
David Langley
2026-06-29 08:55:26 +00:00
committed by GitHub
parent ed768f69e1
commit a0639bfc4b
17 changed files with 690 additions and 33 deletions
@@ -6,11 +6,16 @@
*/
import React, { type JSX, useCallback } from "react";
import { Virtuoso } from "react-virtuoso";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { useVirtualizedList, type VirtualizedListContext, type VirtualizedListProps } from "../virtualized-list";
export interface FlatVirtualizedListProps<Item, Context> extends VirtualizedListProps<Item, Context> {
/**
* Optional ref to the underlying Virtuoso handle, for imperative scrolling.
*/
scrollHandleRef?: React.RefCallback<VirtuosoHandle>;
/**
* Function that renders each list item as a JSX element.
* @param index - The index of the item in the list
@@ -35,8 +40,11 @@ export interface FlatVirtualizedListProps<Item, Context> extends VirtualizedList
* @template Context - The type of additional context data passed to items
*/
export function FlatVirtualizedList<Item, Context>(props: FlatVirtualizedListProps<Item, Context>): React.ReactElement {
const { getItemComponent, ...restProps } = props;
const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList<Item, Context>(restProps);
const { getItemComponent, scrollHandleRef, ...restProps } = props;
const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList<Item, Context>(
restProps,
scrollHandleRef,
);
const getItemComponentInternal = useCallback(
(index: number, item: Item, context: VirtualizedListContext<Context>): JSX.Element =>
@@ -176,6 +176,7 @@ export function useVirtualizedList<Item, Context>(
rangeChanged,
mapScrollIndex,
mapRangeIndex,
scrollerRef: externalScrollerRef,
...virtuosoProps
} = props;
/** Reference to the Virtuoso component for programmatic scrolling */
@@ -329,11 +330,17 @@ export function useVirtualizedList<Item, Context>(
/**
* Callback ref for the Virtuoso scroller element.
* Stores the reference for use in focus management.
* Stores the reference for use in focus management, and forwards it to an
* optional external scrollerRef provided by the consumer (e.g. to observe
* scroll position) since the hook owns the scrollerRef passed to Virtuoso.
*/
const scrollerRef = useCallback((element: HTMLElement | Window | null) => {
virtuosoDomRef.current = element;
}, []);
const scrollerRef = useCallback(
(element: HTMLElement | Window | null) => {
virtuosoDomRef.current = element;
externalScrollerRef?.(element);
},
[externalScrollerRef],
);
/**
* Focus handler passed to each item component.
@@ -195,7 +195,8 @@
"space_menu": {
"home": "Space home",
"space_settings": "Space settings"
}
},
"unread_messages": "Unread messages"
},
"terms": {
"tac_button": "Review terms and conditions"
@@ -18,11 +18,12 @@ const meta = {
args: {
type: "section_created",
onClose: fn(),
onClick: fn(),
},
argTypes: {
type: {
control: "select",
options: ["section_created"],
options: ["section_created", "chat_moved", "unread_activity"],
},
},
decorators: [
@@ -50,3 +51,9 @@ export const ChatMoved: Story = {
type: "chat_moved",
},
};
export const UnreadActivity: Story = {
args: {
type: "unread_activity",
},
};
@@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event";
import * as stories from "./RoomListToast.stories";
const { SectionCreated, ChatMoved } = composeStories(stories);
const { SectionCreated, ChatMoved, UnreadActivity } = composeStories(stories);
describe("<RoomListToast />", () => {
it("renders SectionCreated story", () => {
@@ -26,6 +26,11 @@ describe("<RoomListToast />", () => {
expect(container).toMatchSnapshot();
});
it("renders UnreadActivity story", () => {
const { container } = render(<UnreadActivity />);
expect(container).toMatchSnapshot();
});
it("calls onClose when the close button is clicked", async () => {
const user = userEvent.setup();
render(<SectionCreated />);
@@ -33,4 +38,11 @@ describe("<RoomListToast />", () => {
await user.click(closeButton);
expect(SectionCreated.args.onClose).toHaveBeenCalled();
});
it("calls onClick when the unread-activity toast is clicked", async () => {
const user = userEvent.setup();
render(<UnreadActivity />);
await user.click(screen.getByRole("button", { name: "Unread messages" }));
expect(UnreadActivity.args.onClick).toHaveBeenCalled();
});
});
@@ -7,40 +7,57 @@
import React, { type JSX, type MouseEventHandler } from "react";
import { Toast } from "@vector-im/compound-web";
import ArrowDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/arrow-down";
import styles from "./RoomListToast.module.css";
import { useI18n } from "../../../core/i18n/i18nContext";
export type ToastType = "section_created" | "chat_moved";
export type ToastType =
// Transient, auto-dismissing event toasts with a close button.
| "section_created"
| "chat_moved"
// Persistent, clickable toast surfacing unread activity below the visible area.
| "unread_activity";
interface RoomListToastProps {
/** The type of toast to display */
type: ToastType;
/** Callback when the close button is clicked */
/** Callback when the close button is clicked (event toasts: "section_created", "chat_moved") */
onClose: MouseEventHandler<HTMLButtonElement>;
/** Callback when the toast itself is clicked ("unread_activity") */
onClick: MouseEventHandler<HTMLButtonElement>;
}
/**
* A toast component used for displaying temporary messages in the room list view.
* A toast component used for displaying messages in the room list view.
*
* The room list shows at most one toast at a time; which one (and the precedence between
* transient event toasts and the persistent unread-activity toast) is decided by the view
* model, so the view simply renders whichever {@link ToastType} it is given:
*
* - "section_created" / "chat_moved": transient event notifications with a close button.
* - "unread_activity": a persistent, clickable toast that jumps to the next unread room
* below the visible area of the list.
*
* @example
* ```tsx
* <RoomListToast type="section_created" onClose={onCloseHandler} />
* <RoomListToast type="section_created" onClose={onCloseHandler} onClick={onClickHandler} />
* ```
*/
export function RoomListToast({ type, onClose }: Readonly<RoomListToastProps>): JSX.Element {
export function RoomListToast({ type, onClose, onClick }: Readonly<RoomListToastProps>): JSX.Element {
const { translate: _t } = useI18n();
let text: string;
switch (type) {
case "section_created":
text = _t("room_list|section_created");
break;
case "chat_moved":
text = _t("room_list|chat_moved");
break;
// The unread-activity toast is clickable as a whole (it scrolls to the unread room) rather
// than closeable, so it uses the clickable Toast variant with a leading arrow-down icon.
if (type === "unread_activity") {
return (
<Toast className={styles.toast} Icon={ArrowDownIcon} onClick={onClick}>
{_t("room_list|unread_messages")}
</Toast>
);
}
const text = type === "section_created" ? _t("room_list|section_created") : _t("room_list|chat_moved");
return (
<Toast className={styles.toast} onClose={onClose} tooltip={_t("action|close")}>
{text}
@@ -85,3 +85,35 @@ exports[`<RoomListToast /> > renders SectionCreated story 1`] = `
</div>
</div>
`;
exports[`<RoomListToast /> > renders UnreadActivity story 1`] = `
<div>
<div
style="position: relative; width: 320px; height: 100px; background-color: grey;"
>
<button
class="_toast-container_1jkz7_8 _clickable_1jkz7_33 RoomListToast-module_toast"
type="button"
>
<div
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _content_1jkz7_68"
>
<svg
aria-hidden="true"
class="_icon_1jkz7_55"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 4.5a1 1 0 0 1 1 1v10.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6a1 1 0 0 1-1.414 0l-6-6a1 1 0 1 1 1.414-1.414L11 16.086V5.5a1 1 0 0 1 1-1"
/>
</svg>
Unread messages
</div>
</button>
</div>
</div>
`;
@@ -38,8 +38,11 @@ const RoomListViewWrapperImpl = ({
getRoomItemViewModel,
getSectionHeaderViewModel,
updateVisibleRooms,
updateVisibleFold,
renderAvatar: renderAvatarProp,
closeToast,
scrollToUnreadActivity,
setScrollToIndex,
changeRoomSection,
changeSectionOrder,
onSectionDragStart,
@@ -53,7 +56,10 @@ const RoomListViewWrapperImpl = ({
getRoomItemViewModel,
getSectionHeaderViewModel,
updateVisibleRooms,
updateVisibleFold,
closeToast,
scrollToUnreadActivity,
setScrollToIndex,
changeRoomSection,
changeSectionOrder,
onSectionDragStart,
@@ -106,10 +112,13 @@ const meta = {
getRoomItemViewModel: createGetRoomItemViewModel(mockRoomIds),
getSectionHeaderViewModel: createGetSectionHeaderViewModel(mockSections.map((section) => section.id)),
updateVisibleRooms: fn(),
updateVisibleFold: fn(),
renderAvatar,
isFlatList: true,
toast: undefined,
closeToast: fn(),
scrollToUnreadActivity: fn(),
setScrollToIndex: fn(),
changeRoomSection: fn(),
changeSectionOrder: fn(),
onSectionDragStart: fn(),
@@ -267,3 +276,9 @@ export const Toast: Story = {
toast: "section_created",
},
};
export const UnreadActivityBelow: Story = {
args: {
toast: "unread_activity",
},
};
@@ -50,7 +50,11 @@ export type RoomListViewSnapshot = {
canCreateRoom?: boolean;
/** Whether the room list is displayed as a flat list */
isFlatList: boolean;
/** Optional toast to display */
/**
* The single toast to display (if any). The view model owns which toast wins when more
* than one applies (e.g. a transient "chat_moved" event toast takes precedence over the
* persistent "unread_activity" toast), so the view just renders whatever it is given.
*/
toast?: ToastType;
};
@@ -71,10 +75,24 @@ export interface RoomListViewActions {
getRoomItemViewModel: (roomId: string) => RoomListItemViewModel | undefined;
/** Called when the visible range changes (virtualization API) */
updateVisibleRooms: (startIndex: number, endIndex: number) => void;
/**
* Called when the last genuinely-visible item index changes (excluding the rendered
* overscan buffer), used to decide whether unread activity is below the fold.
*/
updateVisibleFold: (visibleEndIndex: number) => void;
/** Get view model for a specific section header (virtualization API) */
getSectionHeaderViewModel: (sectionId: string) => RoomListSectionHeaderViewModel;
/** Called to close the toast message */
closeToast: () => void;
/** Called to scroll the next unread room below the visible area of the list into view */
scrollToUnreadActivity: () => void;
/**
* Registers (or, with `undefined`, clears) the imperative scroll handler the view model
* uses to scroll a virtualized item index into view. The view owns the scroll handle, so
* it provides this on mount; the view model calls it in response to user actions such as
* clicking the "unread activity" toast.
*/
setScrollToIndex: (scrollToIndex: ((index: number) => void) | undefined) => void;
/** Called to change the section of a room */
changeRoomSection: (roomId: string, tag: string) => void;
/** Called to change the order of sections */
@@ -129,7 +147,13 @@ export const RoomListView: React.FC<RoomListViewProps> = ({ vm, renderAvatar, on
<Flex direction="column" className={styles.list}>
<AutoHideScrollbar className={styles.scrollbar}>
{listBody}
{snapshot.toast && <RoomListToast type={snapshot.toast} onClose={vm.closeToast} />}
{snapshot.toast && (
<RoomListToast
type={snapshot.toast}
onClose={vm.closeToast}
onClick={vm.scrollToUnreadActivity}
/>
)}
</AutoHideScrollbar>
</Flex>
</>
@@ -34,7 +34,10 @@ const RoomListWrapperImpl = ({
getRoomItemViewModel,
getSectionHeaderViewModel,
updateVisibleRooms,
updateVisibleFold,
closeToast,
scrollToUnreadActivity,
setScrollToIndex,
renderAvatar: renderAvatarProp,
changeRoomSection,
changeSectionOrder,
@@ -49,7 +52,10 @@ const RoomListWrapperImpl = ({
getRoomItemViewModel,
getSectionHeaderViewModel,
updateVisibleRooms,
updateVisibleFold,
closeToast,
scrollToUnreadActivity,
setScrollToIndex,
changeRoomSection,
changeSectionOrder,
onSectionDragStart,
@@ -90,9 +96,12 @@ const meta = {
getRoomItemViewModel: createGetRoomItemViewModel(mock10RoomsIds),
getSectionHeaderViewModel: createGetSectionHeaderViewModel(mock10RoomsSections.map((section) => section.id)),
updateVisibleRooms: fn(),
updateVisibleFold: fn(),
renderAvatar,
isFlatList: true,
closeToast: fn(),
scrollToUnreadActivity: fn(),
setScrollToIndex: fn(),
changeRoomSection: fn(),
changeSectionOrder: fn(),
onSectionDragStart: fn(),
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from "react";
import { type ScrollIntoViewLocation, type VirtuosoHandle } from "react-virtuoso";
import { isEqual } from "lodash";
import { DragDropProvider, DragOverlay, useDragOperation } from "@dnd-kit/react";
@@ -131,6 +131,102 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
const setVirtuosoHandle = useCallback((handle: VirtuosoHandle | null) => {
virtuosoHandleRef.current = handle;
}, []);
// --- "Unread activity" toast fold tracking ---
// Virtuoso renders a large overscan buffer (EXTENDED_VIEWPORT_HEIGHT) below the
// visible area, so its reported range extends well past the actual fold. To show
// the toast as soon as an unread room scrolls just below the fold (rather than only
// once it leaves the overscan buffer), we measure the genuinely-visible last item
// from the scroller geometry and report it separately to the view model.
const foldScrollerRef = useRef<HTMLElement | null>(null);
const foldObserverRef = useRef<IntersectionObserver | null>(null);
// Observed item elements → whether each is currently on screen (intersecting). The keys
// are what we've asked the observer to watch; the values are their latest visibility.
const itemVisibilityRef = useRef<Map<Element, boolean>>(new Map());
const foldSyncRafRef = useRef<number | null>(null);
const lastReportedFoldIndex = useRef<number>(-1);
// Report the highest-index currently-visible item as the fold. Indices are read from
// each element's live data-item-index attribute (rather than a captured value) because
// Virtuoso recycles/reorders item DOM nodes as the list scrolls.
const reportFold = useCallback(() => {
let fold = -1;
for (const [el, isVisible] of itemVisibilityRef.current) {
if (!isVisible) continue;
const index = Number((el as HTMLElement).dataset.itemIndex);
if (Number.isFinite(index) && index > fold) fold = index;
}
if (fold !== lastReportedFoldIndex.current) {
lastReportedFoldIndex.current = fold;
vm.updateVisibleFold(fold);
}
}, [vm]);
// IntersectionObserver callback: track which item elements are genuinely on screen
// (excluding the overscan buffer). Fires as the user scrolls or the viewport resizes,
// with no per-frame layout reads.
const onItemIntersection = useCallback(
(entries: IntersectionObserverEntry[]) => {
for (const entry of entries) {
itemVisibilityRef.current.set(entry.target, entry.isIntersecting);
}
reportFold();
},
[reportFold],
);
// Observe newly-rendered item elements and release ones Virtuoso has recycled out of the
// DOM. The observer itself handles visibility as the user scrolls/resizes, so this only
// needs running when the rendered set changes (rangeChanged) or on first attach.
const syncObservedItems = useCallback(() => {
const scroller = foldScrollerRef.current;
const observer = foldObserverRef.current;
if (!scroller || !observer) return;
const current = new Set<Element>(scroller.querySelectorAll("[data-item-index]"));
for (const el of current) {
if (!itemVisibilityRef.current.has(el)) {
observer.observe(el);
itemVisibilityRef.current.set(el, false); // observed, not yet known visible
}
}
for (const el of itemVisibilityRef.current.keys()) {
if (!current.has(el)) {
observer.unobserve(el);
itemVisibilityRef.current.delete(el);
}
}
reportFold();
}, [reportFold]);
const scheduleSyncObservedItems = useCallback(() => {
if (foldSyncRafRef.current !== null) return;
foldSyncRafRef.current = requestAnimationFrame(() => {
foldSyncRafRef.current = null;
syncObservedItems();
});
}, [syncObservedItems]);
// Callback ref for Virtuoso's scroller element: (re)create an IntersectionObserver rooted
// at it. The initial sync is covered by the rangeChanged Virtuoso fires on mount.
const setScroller = useCallback(
(element: HTMLElement | Window | null) => {
foldObserverRef.current?.disconnect();
foldObserverRef.current = null;
itemVisibilityRef.current.clear();
lastReportedFoldIndex.current = -1;
if (foldSyncRafRef.current !== null) {
cancelAnimationFrame(foldSyncRafRef.current);
foldSyncRafRef.current = null;
}
const scroller = element instanceof HTMLElement ? element : null;
foldScrollerRef.current = scroller;
if (scroller) {
foldObserverRef.current = new IntersectionObserver(onItemIntersection, { root: scroller });
scheduleSyncObservedItems();
}
},
[onItemIntersection, scheduleSyncObservedItems],
);
const roomIds = useMemo(() => sections.flatMap((section) => section.roomIds), [sections]);
const roomCount = roomIds.length;
const sectionCount = sections.length;
@@ -152,8 +248,10 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
const rangeChanged = useCallback(
(range: { startIndex: number; endIndex: number }) => {
vm.updateVisibleRooms(range.startIndex, range.endIndex);
// The rendered set changed; (un)observe items so the fold stays accurate.
scheduleSyncObservedItems();
},
[vm],
[vm, scheduleSyncObservedItems],
);
// Builds the accessibility plugin (live-region announcements) for keyboard/pointer drags,
@@ -363,6 +461,17 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
virtuosoHandleRef.current?.scrollIntoView({ index: flatIndex, align: "start", behavior: "auto" });
}, [scrollToSectionTag, sections]);
// Give the view model an imperative handle to scroll an item index into view (e.g. when the
// user clicks the "unread activity" toast, which is rendered by a sibling component). The view
// owns the scroll handle, so it registers the function here rather than the model pushing
// scroll requests through its snapshot.
useEffect(() => {
vm.setScrollToIndex((index) =>
virtuosoHandleRef.current?.scrollIntoView({ index, align: "center", behavior: "auto" }),
);
return () => vm.setScrollToIndex(undefined);
}, [vm]);
const isItemFocusable = useCallback(() => true, []);
const isGroupHeaderFocusable = useCallback(() => true, []);
const increaseViewportBy = useMemo(
@@ -384,6 +493,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
getItemKey,
isItemFocusable,
rangeChanged,
"scrollerRef": setScroller,
onKeyDown,
increaseViewportBy,
"className": styles.roomList,
@@ -394,6 +504,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
<FlatVirtualizedList
{...commonProps}
{...getContainerAccessibleProps("listbox")}
scrollHandleRef={setVirtuosoHandle}
items={roomIds}
getItemComponent={getItemComponentForFlatList}
/>