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:
+11
-3
@@ -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"
|
||||
|
||||
+8
-1
@@ -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
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+30
-13
@@ -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}
|
||||
|
||||
+32
@@ -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>
|
||||
</>
|
||||
|
||||
+9
@@ -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(),
|
||||
|
||||
+113
-2
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user