diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-unread-toast.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-unread-toast.spec.ts new file mode 100644 index 00000000000..f1b94d16c80 --- /dev/null +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-unread-toast.spec.ts @@ -0,0 +1,205 @@ +/* + * 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 Page } from "@playwright/test"; +import { rejectToast } from "@element-hq/element-web-playwright-common"; + +import { expect, test } from "../../../element-web-test"; +import { type ElementAppPage } from "../../../pages/ElementAppPage"; +import { getRoomList, getRoomOptionsMenu, getSectionHeader } from "./utils"; + +/** + * The unread-activity toast ("Unread messages") appears at the bottom of the room list when a room with a + * notification count (the green decoration) is scrolled below the visible area. Clicking it scrolls that + * room into view. Rooms with only an unread-activity dot (white/black) must not trigger it. + */ +test.describe("Room list unread activity toast", () => { + test.use({ + displayName: "Alice", + botCreateOpts: { + displayName: "BotBob", + autoAcceptInvites: true, + }, + }); + + const getToast = (page: Page) => page.getByRole("button", { name: "Unread messages" }); + + /** + * Create `count` filler rooms whose names sort alphabetically before any room named "zzz …", + * so that under A-Z sorting they fill the top of the list and push the "zzz …" room below the fold. + */ + async function createFillerRooms(app: ElementAppPage, count: number): Promise { + for (let i = 0; i < count; i++) { + await app.client.createRoom({ name: `room ${String(i).padStart(2, "0")}` }); + } + } + + /** Switch the room list to alphabetical sorting so room positions are deterministic. */ + async function sortAlphabetically(page: Page): Promise { + await getRoomOptionsMenu(page).click(); + await page.getByRole("menuitemradio", { name: "A-Z" }).click(); + } + + test.describe("flat list", () => { + test.use({ labsFlags: ["feature_new_room_list"] }); + + test.beforeEach(async ({ page, app, user }) => { + // Toasts are displayed above the room list; dismiss the unrelated ones. + await rejectToast(page, "Verify this device"); + await rejectToast(page, "Notifications"); + // Focus the user menu so room rows are not decorated by hover. + await page.getByRole("button", { name: "User menu" }).focus(); + }); + + test("shows a toast for a notifying room below the fold and scrolls to it on click", async ({ + page, + app, + bot, + }) => { + const roomList = getRoomList(page); + + // A room with a real notification count, named so it sorts to the very bottom under A-Z. + const targetId = await app.client.createRoom({ name: "zzz unread room" }); + await app.client.inviteUser(targetId, bot.credentials.userId); + await bot.joinRoom(targetId); + + // Enough filler rooms to push the target well below the visible area. + await createFillerRooms(app, 20); + + await sortAlphabetically(page); + + // The bot notifies the target room, producing a green notification count. + await bot.sendMessage(targetId, "Hello from the bottom!"); + + const targetRow = roomList.getByRole("option", { name: "Open room zzz unread room" }); + + // The toast appears because the notifying room is below the fold, and the room itself is offscreen. + await expect(getToast(page)).toBeVisible(); + await expect(targetRow).not.toBeInViewport(); + + // Clicking the toast scrolls the notifying room into view… + await getToast(page).click(); + await expect(targetRow).toBeInViewport(); + + // …and the toast goes away once there is nothing left unread below the fold. + await expect(getToast(page)).not.toBeVisible(); + }); + + test("does not show a toast when the only unread room below the fold has an activity dot", async ({ + page, + app, + bot, + }) => { + const roomList = getRoomList(page); + + // Another room to park on, so the activity room stays unread (focused rooms are marked read). + const otherRoomId = await app.client.createRoom({ name: "aaa other room" }); + + // The target's unread state will only ever be an activity dot, never a notification count: set it + // to "@mentions & keywords" so a plain (non-mention) message produces activity rather than a count. + const targetId = await app.client.createRoom({ name: "zzz activity room" }); + await app.client.inviteUser(targetId, bot.credentials.userId); + await bot.joinRoom(targetId); + + await app.viewRoomById(targetId); + await app.settings.openRoomSettings("Notifications"); + await page.getByText("@mentions & keywords").click(); + await app.settings.closeDialog(); + + // Enable showing activity (dots) in the room list, so the activity dot is actually rendered. + await app.settings.openUserSettings("Notifications"); + await page + .getByRole("switch", { name: "Show all activity in the room list (dots or number of unread messages)" }) + .check(); + await app.settings.closeDialog(); + + // Park on the other room so the target stays unread, then send a plain (non-mention) message. + await app.viewRoomById(otherRoomId); + await bot.sendMessage(targetId, "just activity, no mention"); + + // The target shows an unread-activity dot: a decoration with no count (no digits). + const targetRow = roomList.getByRole("option", { name: "Open room zzz activity room" }); + const decoration = targetRow.getByTestId("notification-decoration"); + await expect(decoration).toBeVisible(); + await expect(decoration).not.toHaveText(/\d/); + + // Push the activity-dot room below the fold with filler rooms and A-Z sorting. + await createFillerRooms(app, 20); + await sortAlphabetically(page); + + // The list has settled (a top filler room is visible) but the activity dot must not raise the toast. + await expect(roomList.getByRole("option", { name: "Open room room 00" })).toBeVisible(); + await expect(targetRow).not.toBeInViewport(); + await expect(getToast(page)).not.toBeVisible(); + }); + }); + + test.describe("sections", () => { + test.use({ labsFlags: ["feature_new_room_list", "feature_room_list_sections"] }); + + test.beforeEach(async ({ page, app, user }) => { + await rejectToast(page, "Verify this device"); + await rejectToast(page, "Notifications"); + await page.getByRole("button", { name: "User menu" }).focus(); + }); + + test("shows a toast for a collapsed section that hides a notifying room", async ({ page, app, bot }) => { + const roomList = getRoomList(page); + + // A regular (Chats) room with a notification count. + const notifyId = await app.client.createRoom({ name: "chats notify room" }); + await app.client.inviteUser(notifyId, bot.credentials.userId); + await bot.joinRoom(notifyId); + + // A favourite room so the list renders in section mode from the start. + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + const chatsHeader = getSectionHeader(page, "Chats"); + await expect(chatsHeader).toBeVisible(); + + // Notify the Chats room and collapse the section while its header is still on screen. + await bot.sendMessage(notifyId, "Hidden in a collapsed section"); + await expect( + roomList + .getByRole("row", { name: "Open room chats notify room" }) + .getByTestId("notification-decoration"), + ).toBeVisible(); + await chatsHeader.click(); + await expect(chatsHeader).toHaveAttribute("aria-expanded", "false"); + + // Grow the Favourites section until the collapsed Chats header is pushed below the fold. + for (let i = 0; i < 20; i++) { + const id = await app.client.createRoom({ name: `favourite ${String(i).padStart(2, "0")}` }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, id); + } + + // Wait until the collapsed Chats header has been pushed offscreen (all favourites synced). + await expect(chatsHeader).not.toBeInViewport(); + + // Tagging rooms into the Favourites section raises a transient "Chat moved" toast, which + // shares the single toast slot with — and takes precedence over — the unread-activity toast + // (see RoomListView). Dismiss it via its close button so the unread toast can surface; by now + // all favourites have synced, so it will not re-appear. + const chatMovedToast = page.getByText("Chat moved"); + await expect(chatMovedToast).toBeVisible(); + await page.getByRole("button", { name: "Close" }).click(); + await expect(chatMovedToast).not.toBeVisible(); + + // The collapsed Chats header is offscreen, but its hidden notification raises the toast. + await expect(getToast(page)).toBeVisible(); + + // Clicking the toast scrolls the collapsed section header into view. + await getToast(page).click(); + await expect(chatsHeader).toBeInViewport(); + }); + }); +}); diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index c056b43d7d4..2fa9c0c04c5 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -29,7 +29,10 @@ import RoomListStoreV3, { type Section, } from "../../stores/room-list-v3/RoomListStoreV3"; import { FilterEnum } from "../../stores/room-list-v3/skip-list/filters"; -import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; +import { + RoomNotificationStateStore, + UPDATE_STATUS_INDICATOR, +} from "../../stores/notifications/RoomNotificationStateStore"; import { RoomListItemViewModel } from "./RoomListItemViewModel"; import { SdkContextClass } from "../../contexts/SDKContext"; import { hasCreateRoomRights } from "./utils"; @@ -101,10 +104,41 @@ export class RoomListViewModel private readonly savedExpansionStates = new Map(); /** - * Reference to the currently displayed toast, used to automatically close the toast after a timeout. + * Reference to the currently displayed event toast's auto-close timer, used to dismiss it + * after a timeout (see {@link showToast}). */ private toastRef?: number; + /** + * The currently active transient event toast ("section_created" / "chat_moved"), if any. + * Distinct from the derived "unread_activity" toast: this is set imperatively by an event + * and auto-dismisses, whereas unread activity is recomputed from list/notification state. + * {@link recomputeToast} reconciles the two into the single {@link RoomListViewSnapshot.toast}. + */ + private eventToast?: ToastType; + + /** + * Whether there is currently unread activity (a notification count) in a room scrolled below + * the visible area of the list. Recomputed by {@link updateUnreadActivityBelow}; surfaced as + * the "unread_activity" toast by {@link recomputeToast} when no event toast takes precedence. + */ + private hasUnreadActivityBelow = false; + + /** + * The last genuinely-visible index reported by the virtualized list (excluding the + * rendered overscan buffer), in the list's own entry space (room indices for a flat + * list; including a slot per section header for a grouped list). Initialised to -1 so + * that nothing is considered "below the fold" until the view reports the fold. + */ + private foldIndex = -1; + + /** + * Imperative scroll handle registered by the view (see {@link setScrollToIndex}). The view + * owns the virtualized list's scroll handle, so it provides this; we call it to scroll a + * given item index into view in response to user actions. + */ + private scrollToIndex?: (index: number) => void; + public constructor(props: RoomListViewModelProps) { const activeSpace = SpaceStore.instance.activeSpaceRoom; @@ -172,6 +206,14 @@ export class RoomListViewModel this.onRoomTagged, ); + // Recompute the "unread activity below" toast when room notification state + // changes (e.g. a room below the fold becomes unread, or is marked read). + this.disposables.trackListener( + RoomNotificationStateStore.instance, + UPDATE_STATUS_INDICATOR as any, + this.updateUnreadActivityBelow, + ); + // Subscribe to active room changes to update selected room const dispatcherRef = dispatcher.register(this.onDispatch); this.disposables.track(() => { @@ -311,8 +353,117 @@ export class RoomListViewModel this.roomItemViewModels.delete(roomId); } } + + // The rendered range changed, so re-evaluate whether unread activity is below the fold. + this.updateUnreadActivityBelow(); } + /** + * Update the last genuinely-visible item index (excluding the rendered overscan + * buffer), reported by the view from the scroller geometry. This is what the + * "unread activity" toast uses to decide what is below the fold, so that the toast + * appears as soon as an unread room scrolls just out of view rather than only once + * it leaves the overscan buffer. + */ + public updateVisibleFold = (visibleEndIndex: number): void => { + if (this.foldIndex === visibleEndIndex) return; + this.foldIndex = visibleEndIndex; + this.updateUnreadActivityBelow(); + }; + + /** + * Find the first room with an unread-message notification (a count badge — the green or + * red decoration, not just the unread-activity dot) positioned below the visible area. + * + * "Below the fold" is determined from the last visible index reported by the + * virtualized list (see {@link updateVisibleRooms}). For a grouped list the + * virtualized list interleaves a section-header entry before each section's + * rooms, so we walk the rooms in the same entry order the view renders and + * compare against that index space. + * + * Collapsed sections render only their header (their rooms are removed from the + * displayed sections), so their notifying rooms are not directly reachable. When a + * collapsed section's header is itself below the fold and the section contains a + * notifying room, we surface the header as the target so clicking the toast scrolls + * it into view (revealing the header's aggregated notification badge). + * + * @returns The next notifying room below the fold, or undefined if there is none + * (or the view has not yet reported a visible range). + */ + private firstUnreadRoomBelowFold(): { room: Room; index: number } | undefined { + if (this.foldIndex < 0) return undefined; + + // Only surface rooms showing a notification badge (a count/symbol — the green or red + // decoration), not rooms with just the unread-activity dot. + const hasNotification = (room: Room): boolean => + RoomNotificationStateStore.instance.getRoomState(room).hasUnreadCount; + + if (this.snapshot.current.isFlatList) { + // Flat list: virtualized indices map 1:1 to rooms. + const rooms = this.sections.flatMap((section) => section.rooms); + for (let i = this.foldIndex + 1; i < rooms.length; i++) { + if (hasNotification(rooms[i])) return { room: rooms[i], index: i }; + } + return undefined; + } + + // Full (pre-collapse) rooms per section tag, so we can detect unreads hidden inside + // collapsed sections whose displayed rooms have been emptied. + const fullRoomsByTag = new Map(this.roomsResult.sections.map((section) => [section.tag, section.rooms])); + + // Grouped list: each section contributes a header entry followed by its rooms, so the + // index we return is in the virtualized list's entry space (matching scrollIntoView). + let entryIndex = -1; + for (const section of this.sections) { + entryIndex++; // section header entry + + const isExpanded = this.roomSectionHeaderViewModels.get(section.tag)?.isExpanded ?? true; + if (!isExpanded) { + // Collapsed: rooms aren't rendered, so the header is the only entry. If it is + // below the fold and hides an unread room, target the header itself. + if (entryIndex > this.foldIndex) { + const notifyingRoom = (fullRoomsByTag.get(section.tag) ?? []).find(hasNotification); + if (notifyingRoom) return { room: notifyingRoom, index: entryIndex }; + } + continue; + } + + for (const room of section.rooms) { + entryIndex++; // this room's entry + if (entryIndex > this.foldIndex && hasNotification(room)) return { room, index: entryIndex }; + } + } + return undefined; + } + + /** + * Recompute whether there is unread activity below the visible area, reconciling the + * displayed toast if it changed. + */ + private updateUnreadActivityBelow = (): void => { + const hasUnreadActivityBelow = this.firstUnreadRoomBelowFold() !== undefined; + if (this.hasUnreadActivityBelow === hasUnreadActivityBelow) return; + this.hasUnreadActivityBelow = hasUnreadActivityBelow; + this.recomputeToast(); + }; + + /** + * Register (or clear) the view's imperative scroll handler. Called by the view on mount + * since it owns the virtualized list's scroll handle. + */ + public setScrollToIndex = (scrollToIndex: ((index: number) => void) | undefined): void => { + this.scrollToIndex = scrollToIndex; + }; + + /** + * Scroll the next unread room below the visible area of the list into view (without opening + * it). Invoked when the user clicks the "unread activity" toast. + */ + public scrollToUnreadActivity = (): void => { + const target = this.firstUnreadRoomBelowFold(); + if (target) this.scrollToIndex?.(target.index); + }; + private onDispatch = (payload: any): void => { if (payload.action === Action.ActiveRoomChanged) { // When the active room changes, update the room list data to reflect the new selected room @@ -595,6 +746,9 @@ export class RoomListViewModel }); this.notifyCollapseState(isFlatList); + + // Room list / sections changed: re-evaluate the unread-activity toast. + this.updateUnreadActivityBelow(); } /** @@ -654,20 +808,33 @@ export class RoomListViewModel public closeToast: () => void = () => { clearTimeout(this.toastRef); - this.snapshot.merge({ - toast: undefined, - }); + this.eventToast = undefined; + this.recomputeToast(); }; private showToast(toast: ToastType): void { clearTimeout(this.toastRef); - this.snapshot.merge({ toast }); + this.eventToast = toast; + this.recomputeToast(); // Automatically close the toast after 15 seconds this.toastRef = setTimeout(() => { this.closeToast(); }, 15 * 1000); } + /** + * Reconcile the single toast shown by the view from the two independent sources: the + * transient event toast (which takes precedence and auto-dismisses) and the derived + * unread-activity state. The snapshot is only updated when the effective toast changes, + * to avoid unnecessary re-renders. + */ + private recomputeToast(): void { + const toast = this.eventToast ?? (this.hasUnreadActivityBelow ? "unread_activity" : undefined); + if (this.snapshot.current.toast !== toast) { + this.snapshot.merge({ toast }); + } + } + public changeSectionOrder = async (sourceTag: string, targetTag: string): Promise => { await RoomListStoreV3.instance.reorderSection(sourceTag, targetTag); // Scroll to the section after it moved diff --git a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx index 3c312d19661..c2dd2b8aea7 100644 --- a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx @@ -25,6 +25,8 @@ import { tagRoom } from "../../../src/utils/room/tagRoom"; import { getSectionTagForRoom } from "../../../src/utils/room/getSectionTagForRoom"; import { CHATS_TAG, CUSTOM_SECTION_TAG_PREFIX } from "../../../src/stores/room-list-v3/section"; import { MetaSpace } from "../../../src/stores/spaces"; +import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore"; +import { type RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState"; jest.mock("../../../src/utils/room/tagRoom", () => ({ tagRoom: jest.fn(), @@ -688,6 +690,38 @@ describe("RoomListViewModel", () => { jest.advanceTimersByTime(5 * 1000); expect(viewModel.getSnapshot().toast).toBeUndefined(); }); + + /** Make only `room3` report an unread count, so it is the unread room below the fold. */ + const mockRoom3Unread = (): void => { + jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation( + (room) => ({ hasUnreadCount: room === room3 }) as unknown as RoomNotificationState, + ); + }; + + it("should show the unread-activity toast when an unread room is below the fold", () => { + mockRoom3Unread(); + viewModel = new RoomListViewModel({ client: matrixClient }); + + // room1/room2 visible, room3 (unread) scrolled below the fold. + viewModel.updateVisibleFold(1); + + expect(viewModel.getSnapshot().toast).toBe("unread_activity"); + }); + + it("should prefer the event toast over the unread-activity toast, restoring it on auto-close", () => { + mockRoom3Unread(); + viewModel = new RoomListViewModel({ client: matrixClient }); + viewModel.updateVisibleFold(1); + expect(viewModel.getSnapshot().toast).toBe("unread_activity"); + + // A transient event toast takes precedence over the persistent unread-activity toast… + RoomListStoreV3.instance.emit(RoomListStoreV3Event.RoomTagged); + expect(viewModel.getSnapshot().toast).toBe("chat_moved"); + + // …and once it auto-dismisses, the unread-activity toast returns. + jest.advanceTimersByTime(15 * 1000); + expect(viewModel.getSnapshot().toast).toBe("unread_activity"); + }); }); describe("Sections (feature_room_list_sections)", () => { diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx/unread-activity-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx/unread-activity-auto.png new file mode 100644 index 00000000000..b9fd4809149 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx/unread-activity-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/unread-activity-below-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/unread-activity-below-auto.png new file mode 100644 index 00000000000..fed0068100f Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/unread-activity-below-auto.png differ diff --git a/packages/shared-components/src/core/VirtualizedList/FlatVirtualizedList/FlatVirtualizedList.tsx b/packages/shared-components/src/core/VirtualizedList/FlatVirtualizedList/FlatVirtualizedList.tsx index 7373092a944..e414451149a 100644 --- a/packages/shared-components/src/core/VirtualizedList/FlatVirtualizedList/FlatVirtualizedList.tsx +++ b/packages/shared-components/src/core/VirtualizedList/FlatVirtualizedList/FlatVirtualizedList.tsx @@ -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 extends VirtualizedListProps { + /** + * Optional ref to the underlying Virtuoso handle, for imperative scrolling. + */ + scrollHandleRef?: React.RefCallback; + /** * 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 extends VirtualizedList * @template Context - The type of additional context data passed to items */ export function FlatVirtualizedList(props: FlatVirtualizedListProps): React.ReactElement { - const { getItemComponent, ...restProps } = props; - const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList(restProps); + const { getItemComponent, scrollHandleRef, ...restProps } = props; + const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList( + restProps, + scrollHandleRef, + ); const getItemComponentInternal = useCallback( (index: number, item: Item, context: VirtualizedListContext): JSX.Element => diff --git a/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx b/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx index 9b107785b84..6a586f3978a 100644 --- a/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx +++ b/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx @@ -176,6 +176,7 @@ export function useVirtualizedList( rangeChanged, mapScrollIndex, mapRangeIndex, + scrollerRef: externalScrollerRef, ...virtuosoProps } = props; /** Reference to the Virtuoso component for programmatic scrolling */ @@ -329,11 +330,17 @@ export function useVirtualizedList( /** * 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. diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index b2fba87061e..712b8a37754 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -195,7 +195,8 @@ "space_menu": { "home": "Space home", "space_settings": "Space settings" - } + }, + "unread_messages": "Unread messages" }, "terms": { "tac_button": "Review terms and conditions" diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx index f34e5b5a850..83edd409794 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx @@ -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", + }, +}; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx index e9f4303e52d..7aa39162e09 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx @@ -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("", () => { it("renders SectionCreated story", () => { @@ -26,6 +26,11 @@ describe("", () => { expect(container).toMatchSnapshot(); }); + it("renders UnreadActivity story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("calls onClose when the close button is clicked", async () => { const user = userEvent.setup(); render(); @@ -33,4 +38,11 @@ describe("", () => { 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(); + await user.click(screen.getByRole("button", { name: "Unread messages" })); + expect(UnreadActivity.args.onClick).toHaveBeenCalled(); + }); }); diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx index 1a2e4c194c3..ebdedeb3e93 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx @@ -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; + /** Callback when the toast itself is clicked ("unread_activity") */ + onClick: MouseEventHandler; } /** - * 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 - * + * * ``` */ -export function RoomListToast({ type, onClose }: Readonly): JSX.Element { +export function RoomListToast({ type, onClose, onClick }: Readonly): 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 ( + + {_t("room_list|unread_messages")} + + ); } + const text = type === "section_created" ? _t("room_list|section_created") : _t("room_list|chat_moved"); return ( {text} diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap b/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap index 05148b39789..6e1bc733a7c 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap @@ -85,3 +85,35 @@ exports[` > renders SectionCreated story 1`] = ` `; + +exports[` > renders UnreadActivity story 1`] = ` +
+
+ +
+
+`; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx index 144f9c135cc..f15567d3a8a 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx @@ -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", + }, +}; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx index da88149e9ed..6de5ab2f297 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx @@ -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 = ({ vm, renderAvatar, on {listBody} - {snapshot.toast && } + {snapshot.toast && ( + + )} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx index aa2e76e8e6f..17716a0a13f 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx @@ -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(), diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx index cd2eaa7864f..a490e3d1394 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx @@ -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(null); + const foldObserverRef = useRef(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>(new Map()); + const foldSyncRafRef = useRef(null); + const lastReportedFoldIndex = useRef(-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(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 diff --git a/packages/shared-components/vitest.config.ts b/packages/shared-components/vitest.config.ts index b2f0f71dfd1..537b1de38bf 100644 --- a/packages/shared-components/vitest.config.ts +++ b/packages/shared-components/vitest.config.ts @@ -117,7 +117,15 @@ export default defineConfig({ "vite-plugin-node-polyfills/shims/process", "@vector-im/compound-design-tokens/assets/web/icons", "storybook/preview-api", + // The room-list view pulls in a heavy dnd-kit + react-virtuoso graph. If these are left to + // runtime discovery, the browser-mode dep optimizer can re-bundle mid-run and reload the page, + // which fails the in-flight setupTests.ts import for the room-list unit suites. Pre-bundle the + // whole graph up front so the optimizer never needs to re-run while tests are loading. "@dnd-kit/abstract", + "@dnd-kit/abstract/modifiers", + "@dnd-kit/dom", + "@dnd-kit/react", + "react-virtuoso", ], }, resolve: {