Room list: add activity marker to sections (#33024)
* feat: add unread status to section view * feat: add unread tracking in room list section * feat: populate rooms into section header vm * test: add units for unread in section view model * test(e2e): add unread tests
This commit is contained in:
@@ -39,9 +39,12 @@ test.describe("Room list sections", () => {
|
||||
* Get a section header toggle button by section name
|
||||
* @param page
|
||||
* @param sectionName The display name of the section (e.g. "Favourites", "Chats", "Low Priority")
|
||||
* @param isUnread Whether to look for the unread version of the section header
|
||||
*/
|
||||
function getSectionHeader(page: Page, sectionName: string): Locator {
|
||||
return getRoomList(page).getByRole("gridcell", { name: `Toggle ${sectionName} section` });
|
||||
function getSectionHeader(page: Page, sectionName: string, isUnread = false): Locator {
|
||||
return getRoomList(page).getByRole("gridcell", {
|
||||
name: isUnread ? `Toggle ${sectionName} section with unread room(s)` : `Toggle ${sectionName} section`,
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, app, user }) => {
|
||||
@@ -209,6 +212,31 @@ test.describe("Room list sections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should show unread indicator on section header", async ({ page, app, bot }) => {
|
||||
// Create a favourite room
|
||||
const favouriteId = await app.client.createRoom({ name: "favourite room" });
|
||||
await app.client.evaluate(async (client, roomId) => {
|
||||
await client.setRoomTag(roomId, "m.favourite");
|
||||
}, favouriteId);
|
||||
|
||||
const roomList = getRoomList(page);
|
||||
|
||||
// Invite the bot and have it send a message to generate an unread
|
||||
await app.client.inviteUser(favouriteId, bot.credentials.userId);
|
||||
await bot.joinRoom(favouriteId);
|
||||
await bot.sendMessage(favouriteId, "Hello from bot!");
|
||||
|
||||
let sectionHeader = getSectionHeader(page, "Favourites", true);
|
||||
await expect(sectionHeader).toBeVisible();
|
||||
|
||||
// Open the room to mark it as read
|
||||
await roomList.getByRole("row", { name: "Open room favourite room" }).click();
|
||||
|
||||
// The section should no longer be unread
|
||||
sectionHeader = getSectionHeader(page, "Favourites", false);
|
||||
await expect(sectionHeader).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("Sections and filters interaction", () => {
|
||||
test("should not show Favourite and Low Priority filters when sections are enabled", async ({ page, app }) => {
|
||||
const primaryFilters = getPrimaryFilters(page);
|
||||
|
||||
@@ -5,12 +5,17 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
BaseViewModel,
|
||||
type RoomListSectionHeaderActions,
|
||||
type RoomListSectionHeaderViewSnapshot,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
|
||||
import { NotificationStateEvents } from "../../stores/notifications/NotificationState";
|
||||
import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
|
||||
|
||||
interface RoomListSectionHeaderViewModelProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
@@ -21,8 +26,13 @@ export class RoomListSectionHeaderViewModel
|
||||
extends BaseViewModel<RoomListSectionHeaderViewSnapshot, RoomListSectionHeaderViewModelProps>
|
||||
implements RoomListSectionHeaderActions
|
||||
{
|
||||
/**
|
||||
* The notification states of the rooms currently in this section, used to compute the unread state.
|
||||
*/
|
||||
private roomNotificationStates = new Set<RoomNotificationState>();
|
||||
|
||||
public constructor(props: RoomListSectionHeaderViewModelProps) {
|
||||
super(props, { id: props.tag, title: props.title, isExpanded: true });
|
||||
super(props, { id: props.tag, title: props.title, isExpanded: true, isUnread: false });
|
||||
}
|
||||
|
||||
public onClick = (): void => {
|
||||
@@ -37,4 +47,47 @@ export class RoomListSectionHeaderViewModel
|
||||
public get isExpanded(): boolean {
|
||||
return this.snapshot.current.isExpanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the rooms tracked by this section header for unread state computation.
|
||||
* Only subscribes to new rooms and unsubscribes from rooms no longer in the section.
|
||||
* @param rooms - The rooms currently in this section
|
||||
*/
|
||||
public setRooms(rooms: Room[]): void {
|
||||
const newStates = new Set(rooms.map((room) => RoomNotificationStateStore.instance.getRoomState(room)));
|
||||
|
||||
// Unsubscribe from rooms no longer in the section
|
||||
for (const state of this.roomNotificationStates) {
|
||||
if (!newStates.has(state)) {
|
||||
state.off(NotificationStateEvents.Update, this.updateUnreadState);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to newly added rooms
|
||||
for (const state of newStates) {
|
||||
if (!this.roomNotificationStates.has(state)) {
|
||||
// We don't use trackListener because we don't want to grow the disposables indefinitely as rooms are added and removed from the section
|
||||
state.on(NotificationStateEvents.Update, this.updateUnreadState);
|
||||
}
|
||||
}
|
||||
|
||||
this.roomNotificationStates = newStates;
|
||||
this.updateUnreadState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the unread state of the section header based on the notification states of the tracked rooms.
|
||||
*/
|
||||
private updateUnreadState = (): void => {
|
||||
const isUnread = [...this.roomNotificationStates].some((state) => state.hasAnyNotificationOrActivity);
|
||||
this.snapshot.merge({ isUnread });
|
||||
};
|
||||
|
||||
public dispose(): void {
|
||||
for (const state of this.roomNotificationStates) {
|
||||
state.off(NotificationStateEvents.Update, this.updateUnreadState);
|
||||
}
|
||||
this.roomNotificationStates.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +491,11 @@ export class RoomListViewModel
|
||||
// Track the current active room position for future sticky calculations
|
||||
this.lastActiveRoomPosition = roomId ? this.findRoomPosition(this.roomsResult.sections, roomId) : undefined;
|
||||
|
||||
// Update section header view models with current rooms for unread state tracking
|
||||
for (const section of this.roomsResult.sections) {
|
||||
this.getSectionHeaderViewModel(section.tag).setRooms(section.rooms);
|
||||
}
|
||||
|
||||
// Build the complete state atomically to ensure consistency
|
||||
const { sections, isFlatList } = computeSections(
|
||||
this.roomsResult,
|
||||
|
||||
@@ -5,13 +5,25 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { RoomListSectionHeaderViewModel } from "../../../src/viewmodels/room-list/RoomListSectionHeaderViewModel";
|
||||
import { RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState";
|
||||
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
|
||||
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
|
||||
import { createTestClient, mkRoom } from "../../test-utils";
|
||||
|
||||
describe("RoomListSectionHeaderViewModel", () => {
|
||||
let onToggleExpanded: jest.Mock;
|
||||
let matrixClient: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
onToggleExpanded = jest.fn();
|
||||
matrixClient = createTestClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should initialize snapshot from props", () => {
|
||||
@@ -45,4 +57,100 @@ describe("RoomListSectionHeaderViewModel", () => {
|
||||
expect(vm.getSnapshot().isExpanded).toBe(true);
|
||||
expect(onToggleExpanded).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
describe("unread status", () => {
|
||||
let room: Room;
|
||||
let notificationState: RoomNotificationState;
|
||||
|
||||
beforeEach(() => {
|
||||
room = mkRoom(matrixClient, "!room:server");
|
||||
notificationState = new RoomNotificationState(room, false);
|
||||
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState);
|
||||
});
|
||||
|
||||
it("should set isUnread to false when no rooms have notifications", () => {
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room]);
|
||||
|
||||
expect(vm.getSnapshot().isUnread).toBe(false);
|
||||
});
|
||||
|
||||
it("should set isUnread to true when a room has notifications", () => {
|
||||
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
|
||||
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room]);
|
||||
|
||||
expect(vm.getSnapshot().isUnread).toBe(true);
|
||||
});
|
||||
|
||||
it("should subscribe to new rooms and unsubscribe from removed rooms", () => {
|
||||
const room2 = mkRoom(matrixClient, "!room2:server");
|
||||
const notificationState2 = new RoomNotificationState(room2, false);
|
||||
|
||||
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState")
|
||||
.mockReturnValueOnce(notificationState)
|
||||
.mockReturnValue(notificationState2);
|
||||
|
||||
jest.spyOn(notificationState, "on");
|
||||
jest.spyOn(notificationState, "off");
|
||||
jest.spyOn(notificationState2, "on");
|
||||
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room]);
|
||||
|
||||
expect(notificationState.on).toHaveBeenCalledWith(NotificationStateEvents.Update, expect.any(Function));
|
||||
|
||||
vm.setRooms([room2]);
|
||||
|
||||
expect(notificationState.off).toHaveBeenCalledWith(NotificationStateEvents.Update, expect.any(Function));
|
||||
expect(notificationState2.on).toHaveBeenCalledWith(NotificationStateEvents.Update, expect.any(Function));
|
||||
|
||||
// Calling setRooms again with the same room should not re-subscribe
|
||||
vm.setRooms([room2]);
|
||||
expect(notificationState2.on).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should update isUnread when a notification state update event fires", () => {
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room]);
|
||||
|
||||
expect(vm.getSnapshot().isUnread).toBe(false);
|
||||
|
||||
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
|
||||
notificationState.emit(NotificationStateEvents.Update);
|
||||
|
||||
expect(vm.getSnapshot().isUnread).toBe(true);
|
||||
});
|
||||
|
||||
it("should unsubscribe from all notification states on dispose", () => {
|
||||
jest.spyOn(notificationState, "off");
|
||||
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room]);
|
||||
|
||||
vm.dispose();
|
||||
expect(notificationState.off).toHaveBeenCalledWith(NotificationStateEvents.Update, expect.any(Function));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
@@ -141,7 +141,8 @@
|
||||
},
|
||||
"room_options": "Room Options",
|
||||
"section_header": {
|
||||
"toggle": "Toggle %(section)s section"
|
||||
"toggle": "Toggle %(section)s section",
|
||||
"toggle_unread": "Toggle %(section)s section with unread room(s)"
|
||||
},
|
||||
"show_message_previews": "Show message previews",
|
||||
"sort": "Sort",
|
||||
|
||||
+5
@@ -46,6 +46,11 @@
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&.unread {
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
color: var(--cpd-color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
|
||||
+7
@@ -56,6 +56,7 @@ const meta = {
|
||||
title: "Favourites",
|
||||
isExpanded: true,
|
||||
isFocused: false,
|
||||
isUnread: false,
|
||||
onClick: fn(),
|
||||
onFocus: fn(),
|
||||
sectionIndex: 1,
|
||||
@@ -107,3 +108,9 @@ export const LastHeaderCollapsed: Story = {
|
||||
sectionIndex: 2,
|
||||
},
|
||||
};
|
||||
|
||||
export const Unread: Story = {
|
||||
args: {
|
||||
isUnread: true,
|
||||
},
|
||||
};
|
||||
|
||||
+9
-2
@@ -25,6 +25,8 @@ export interface RoomListSectionHeaderViewSnapshot {
|
||||
title: string;
|
||||
/** Whether the section is currently expanded. */
|
||||
isExpanded: boolean;
|
||||
/** Whether the section is unread (has any unread rooms) */
|
||||
isUnread: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,7 +91,7 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
|
||||
roomCountInSection,
|
||||
}: Readonly<RoomListSectionHeaderViewProps>): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
const { id, title, isExpanded } = useViewModel(vm);
|
||||
const { id, title, isExpanded, isUnread } = useViewModel(vm);
|
||||
const isLastSection = sectionIndex === sectionCount - 1;
|
||||
|
||||
return (
|
||||
@@ -104,12 +106,17 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
|
||||
[styles.firstHeader]: sectionIndex === 0,
|
||||
// If the section is collapsed and it's the last one
|
||||
[styles.lastHeader]: !isExpanded && isLastSection,
|
||||
[styles.unread]: isUnread,
|
||||
})}
|
||||
onClick={vm.onClick}
|
||||
aria-expanded={isExpanded}
|
||||
onFocus={(e) => onFocus(id, e)}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
aria-label={_t("room_list|section_header|toggle", { section: title })}
|
||||
aria-label={
|
||||
isUnread
|
||||
? _t("room_list|section_header|toggle_unread", { section: title })
|
||||
: _t("room_list|section_header|toggle", { section: title })
|
||||
}
|
||||
>
|
||||
<Flex className={styles.container} align="center" gap="var(--cpd-space-0-5x)">
|
||||
<ChevronRightIcon width="24px" height="24px" fill="var(--cpd-color-icon-secondary)" />
|
||||
|
||||
Reference in New Issue
Block a user