Room list: add notifications to section headers (#33826)
* feat(sc): add notification decoration to header * feat(vm): add notification decoration to vm * test(e2e): add test
This commit is contained in:
@@ -217,29 +217,92 @@ 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);
|
||||
test.describe("Section header notification", () => {
|
||||
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);
|
||||
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!");
|
||||
// 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();
|
||||
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();
|
||||
// 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();
|
||||
// The section should no longer be unread
|
||||
sectionHeader = getSectionHeader(page, "Favourites", false);
|
||||
await expect(sectionHeader).toBeVisible();
|
||||
});
|
||||
|
||||
test(
|
||||
"should aggregate notification decorations on the collapsed section header",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, user, bot }) => {
|
||||
// A favourite room to keep the room list in section mode (otherwise it renders as a flat list)
|
||||
const favouriteId = await app.client.createRoom({ name: "favourite room" });
|
||||
await app.client.evaluate(async (client, roomId) => {
|
||||
await client.setRoomTag(roomId, "m.favourite");
|
||||
}, favouriteId);
|
||||
|
||||
// A room with a mention, landing in the Chats section
|
||||
const mentionId = await app.client.createRoom({ name: "mention room" });
|
||||
await app.client.inviteUser(mentionId, bot.credentials.userId);
|
||||
await bot.joinRoom(mentionId);
|
||||
const clientBot = await bot.prepareClient();
|
||||
await clientBot.evaluate(
|
||||
async (client, { roomId, userId }) => {
|
||||
await client.sendMessage(roomId, {
|
||||
// @ts-ignore ignore usage of MsgType.text
|
||||
"msgtype": "m.text",
|
||||
"body": "User",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": `<a href="https://matrix.to/#/${userId}">User</a>`,
|
||||
"m.mentions": {
|
||||
user_ids: [userId],
|
||||
},
|
||||
});
|
||||
},
|
||||
{ roomId: mentionId, userId: user.userId },
|
||||
);
|
||||
|
||||
// A room we are invited to, landing in the Chats section
|
||||
await bot.createRoom({
|
||||
name: "invited room",
|
||||
invite: [user.userId],
|
||||
is_direct: true,
|
||||
});
|
||||
|
||||
const roomList = getRoomList(page);
|
||||
|
||||
// Wait for the mention decoration to sync onto the mention room before collapsing, so the
|
||||
// section header aggregation has the room states available.
|
||||
await expect(
|
||||
roomList.getByRole("row", { name: /mention room/ }).getByTestId("notification-decoration"),
|
||||
).toBeVisible();
|
||||
|
||||
// Collapse the Chats section so the aggregated decoration is displayed on its header
|
||||
const chatsHeader = getSectionHeader(page, "Chats", true);
|
||||
await expect(chatsHeader).toBeVisible();
|
||||
await chatsHeader.click();
|
||||
|
||||
// The header hides its decoration while hovered/focused, so move the pointer away
|
||||
await page.mouse.move(0, 0);
|
||||
|
||||
// The collapsed header aggregates the mention and the invitation
|
||||
await expect(chatsHeader.getByTestId("notification-decoration")).toBeVisible();
|
||||
|
||||
await expect(chatsHeader).toMatchScreenshot("room-list-section-header-notification.png");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("Sections and filters interaction", () => {
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -6,8 +6,10 @@
|
||||
*/
|
||||
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
import {
|
||||
BaseViewModel,
|
||||
type NotificationDecorationData,
|
||||
type RoomListSectionHeaderActions,
|
||||
type RoomListSectionHeaderViewSnapshot,
|
||||
} from "@element-hq/web-shared-components";
|
||||
@@ -19,6 +21,8 @@ import SettingsStore from "../../settings/SettingsStore";
|
||||
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { getCustomSectionData, isCustomSectionTag, isDefaultSectionTag } from "../../stores/room-list-v3/section";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
|
||||
import { type Call, CallEvent } from "../../models/Call";
|
||||
|
||||
interface RoomListSectionHeaderViewModelProps {
|
||||
tag: string;
|
||||
@@ -45,6 +49,11 @@ export class RoomListSectionHeaderViewModel
|
||||
*/
|
||||
private readonly expandedBySpace = new Map<string, boolean>();
|
||||
|
||||
/**
|
||||
* The calls of the rooms currently in this section that we are listening to, used to aggregate the call decoration.
|
||||
*/
|
||||
private currentCalls = new Set<Call>();
|
||||
|
||||
public constructor(props: RoomListSectionHeaderViewModelProps) {
|
||||
const isDefaultSection = isDefaultSectionTag(props.tag);
|
||||
super(props, {
|
||||
@@ -58,6 +67,9 @@ export class RoomListSectionHeaderViewModel
|
||||
this.onCustomSectionDataChange(),
|
||||
);
|
||||
this.disposables.track(() => SettingsStore.unwatchSetting(sectionWatherRef));
|
||||
|
||||
// Recompute the decoration when a call starts or ends in any room
|
||||
this.disposables.trackListener(CallStore.instance, CallStoreEvent.Call, this.onCallChanged);
|
||||
}
|
||||
|
||||
public onClick = (): void => {
|
||||
@@ -107,7 +119,7 @@ export class RoomListSectionHeaderViewModel
|
||||
// Unsubscribe from rooms no longer in the section
|
||||
for (const state of this.roomNotificationStates) {
|
||||
if (!newStates.has(state)) {
|
||||
state.off(NotificationStateEvents.Update, this.updateUnreadState);
|
||||
state.off(NotificationStateEvents.Update, this.updateNotificationState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,27 +127,112 @@ export class RoomListSectionHeaderViewModel
|
||||
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);
|
||||
state.on(NotificationStateEvents.Update, this.updateNotificationState);
|
||||
}
|
||||
}
|
||||
|
||||
this.roomNotificationStates = newStates;
|
||||
this.updateUnreadState();
|
||||
this.updateCallListeners();
|
||||
this.updateNotificationState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the unread state of the section header based on the notification states of the tracked rooms.
|
||||
* Subscribe to participant/type changes of the calls in the section's rooms, and unsubscribe
|
||||
* from calls that are no longer present. Mirrors the call tracking done per room list item.
|
||||
*/
|
||||
private updateUnreadState = (): void => {
|
||||
const isUnread = [...this.roomNotificationStates].some((state) => state.hasAnyNotificationOrActivity);
|
||||
this.snapshot.merge({ isUnread });
|
||||
private updateCallListeners(): void {
|
||||
const newCalls = new Set<Call>();
|
||||
for (const state of this.roomNotificationStates) {
|
||||
const call = state.room && CallStore.instance.getCall(state.room.roomId);
|
||||
if (call) newCalls.add(call);
|
||||
}
|
||||
|
||||
// Unsubscribe from calls no longer present
|
||||
for (const call of this.currentCalls) {
|
||||
if (!newCalls.has(call)) {
|
||||
call.off(CallEvent.Participants, this.updateNotificationState);
|
||||
call.off(CallEvent.CallTypeChanged, this.updateNotificationState);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to newly added calls
|
||||
for (const call of newCalls) {
|
||||
if (!this.currentCalls.has(call)) {
|
||||
call.on(CallEvent.Participants, this.updateNotificationState);
|
||||
call.on(CallEvent.CallTypeChanged, this.updateNotificationState);
|
||||
}
|
||||
}
|
||||
|
||||
this.currentCalls = newCalls;
|
||||
}
|
||||
|
||||
private onCallChanged = (): void => {
|
||||
this.updateCallListeners();
|
||||
this.updateNotificationState();
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the section header from the notification states of the tracked rooms.
|
||||
* Computes both the unread (bold) state and a merged notification decoration that aggregates
|
||||
* the rooms' notifications. The activity "dot" is intentionally excluded from the decoration.
|
||||
*/
|
||||
private updateNotificationState = (): void => {
|
||||
let isUnread = false;
|
||||
let isMention = false;
|
||||
let isNotification = false;
|
||||
let isUnsentMessage = false;
|
||||
let hasUnreadCount = false;
|
||||
let invited = false;
|
||||
let count = 0;
|
||||
let callType: "video" | "voice" | undefined = undefined;
|
||||
|
||||
for (const state of this.roomNotificationStates) {
|
||||
if (state.hasAnyNotificationOrActivity) isUnread = true;
|
||||
if (state.isMention) isMention = true;
|
||||
if (state.isNotification) isNotification = true;
|
||||
if (state.isUnsentMessage) isUnsentMessage = true;
|
||||
if (state.hasUnreadCount) hasUnreadCount = true;
|
||||
if (state.invited) invited = true;
|
||||
// Mention, notification, Mark as unread are aggregated
|
||||
if (state.isMention || state.isNotification) count += state.count || 1;
|
||||
|
||||
// Aggregate active calls, preferring a video call over a voice call
|
||||
const call = state.room && CallStore.instance.getCall(state.room.roomId);
|
||||
if (call && call.participants.size > 0) {
|
||||
if (call.callType === CallType.Video) callType = "video";
|
||||
else if (call.callType === CallType.Voice && callType !== "video") callType = "voice";
|
||||
}
|
||||
}
|
||||
|
||||
const notification: NotificationDecorationData = {
|
||||
// Drives the decoration's early-return: an activity-only section stays bold but shows no badge
|
||||
hasAnyNotificationOrActivity:
|
||||
isMention || isNotification || isUnsentMessage || invited || Boolean(callType),
|
||||
isUnsentMessage,
|
||||
isMention,
|
||||
isNotification,
|
||||
hasUnreadCount,
|
||||
count,
|
||||
invited,
|
||||
callType,
|
||||
// The activity dot and muted bell are intentionally not aggregated onto the section header
|
||||
isActivityNotification: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
this.snapshot.merge({ isUnread, notification });
|
||||
};
|
||||
|
||||
public dispose(): void {
|
||||
for (const state of this.roomNotificationStates) {
|
||||
state.off(NotificationStateEvents.Update, this.updateUnreadState);
|
||||
state.off(NotificationStateEvents.Update, this.updateNotificationState);
|
||||
}
|
||||
this.roomNotificationStates.clear();
|
||||
for (const call of this.currentCalls) {
|
||||
call.off(CallEvent.Participants, this.updateNotificationState);
|
||||
call.off(CallEvent.CallTypeChanged, this.updateNotificationState);
|
||||
}
|
||||
this.currentCalls.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
*/
|
||||
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
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 { CallStore } from "../../../src/stores/CallStore";
|
||||
import { type Call } from "../../../src/models/Call";
|
||||
import { createTestClient, mkRoom } from "../../test-utils";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
@@ -310,6 +313,229 @@ describe("RoomListSectionHeaderViewModel", () => {
|
||||
expect(vm.getSnapshot().isUnread).toBe(true);
|
||||
});
|
||||
|
||||
describe("notification decoration", () => {
|
||||
it("should expose an empty decoration when no room has notifications", () => {
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
spaceId: "!space:server",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room]);
|
||||
|
||||
expect(vm.getSnapshot().notification).toEqual(
|
||||
expect.objectContaining({
|
||||
hasAnyNotificationOrActivity: false,
|
||||
isMention: false,
|
||||
isNotification: false,
|
||||
isUnsentMessage: false,
|
||||
isActivityNotification: false,
|
||||
count: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not show the activity dot for an activity-only section", () => {
|
||||
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
|
||||
jest.spyOn(notificationState, "isActivityNotification", "get").mockReturnValue(true);
|
||||
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
spaceId: "!space:server",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room]);
|
||||
|
||||
// Bold, but no badge to display
|
||||
expect(vm.getSnapshot().isUnread).toBe(true);
|
||||
expect(vm.getSnapshot().notification).toEqual(
|
||||
expect.objectContaining({
|
||||
hasAnyNotificationOrActivity: false,
|
||||
isActivityNotification: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should merge mentions, notifications and counts across 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, "isMention", "get").mockReturnValue(true);
|
||||
jest.spyOn(notificationState, "count", "get").mockReturnValue(3);
|
||||
jest.spyOn(notificationState, "hasUnreadCount", "get").mockReturnValue(true);
|
||||
|
||||
jest.spyOn(notificationState2, "isNotification", "get").mockReturnValue(true);
|
||||
jest.spyOn(notificationState2, "count", "get").mockReturnValue(9);
|
||||
jest.spyOn(notificationState2, "hasUnreadCount", "get").mockReturnValue(true);
|
||||
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
spaceId: "!space:server",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room, room2]);
|
||||
|
||||
expect(vm.getSnapshot().notification).toEqual(
|
||||
expect.objectContaining({
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isMention: true,
|
||||
isNotification: true,
|
||||
hasUnreadCount: true,
|
||||
count: 12,
|
||||
isActivityNotification: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should surface an unsent message from any room", () => {
|
||||
jest.spyOn(notificationState, "isUnsentMessage", "get").mockReturnValue(true);
|
||||
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
spaceId: "!space:server",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room]);
|
||||
|
||||
expect(vm.getSnapshot().notification).toEqual(
|
||||
expect.objectContaining({
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isUnsentMessage: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should aggregate an invitation from any room", () => {
|
||||
jest.spyOn(notificationState, "invited", "get").mockReturnValue(true);
|
||||
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
spaceId: "!space:server",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room]);
|
||||
|
||||
expect(vm.getSnapshot().notification).toEqual(
|
||||
expect.objectContaining({
|
||||
hasAnyNotificationOrActivity: true,
|
||||
invited: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should aggregate an active call, preferring video over voice", () => {
|
||||
const room2 = mkRoom(matrixClient, "!room2:server");
|
||||
const notificationState2 = new RoomNotificationState(room2, false);
|
||||
|
||||
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState")
|
||||
.mockReturnValueOnce(notificationState)
|
||||
.mockReturnValue(notificationState2);
|
||||
|
||||
const voiceCall = {
|
||||
participants: new Map([["@a:server", new Set(["DEVICE"])]]),
|
||||
callType: CallType.Voice,
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
} as unknown as Call;
|
||||
const videoCall = {
|
||||
participants: new Map([["@b:server", new Set(["DEVICE"])]]),
|
||||
callType: CallType.Video,
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
} as unknown as Call;
|
||||
jest.spyOn(CallStore.instance, "getCall").mockImplementation((roomId) =>
|
||||
roomId === room.roomId ? voiceCall : videoCall,
|
||||
);
|
||||
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
spaceId: "!space:server",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room, room2]);
|
||||
|
||||
expect(vm.getSnapshot().notification).toEqual(
|
||||
expect.objectContaining({
|
||||
hasAnyNotificationOrActivity: true,
|
||||
callType: "video",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should ignore a call without participants", () => {
|
||||
const call = {
|
||||
participants: new Map(),
|
||||
callType: CallType.Video,
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
} as unknown as Call;
|
||||
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(call);
|
||||
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
spaceId: "!space:server",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room]);
|
||||
|
||||
expect(vm.getSnapshot().notification?.callType).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should show a notification without a count badge for a mark-as-unread room", () => {
|
||||
// "Mark as unread" sets level=Notification with count=0 (no real notification events).
|
||||
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
|
||||
jest.spyOn(notificationState, "isNotification", "get").mockReturnValue(true);
|
||||
jest.spyOn(notificationState, "count", "get").mockReturnValue(0);
|
||||
jest.spyOn(notificationState, "hasUnreadCount", "get").mockReturnValue(false);
|
||||
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
spaceId: "!space:server",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room]);
|
||||
|
||||
expect(vm.getSnapshot().isUnread).toBe(true);
|
||||
expect(vm.getSnapshot().notification).toEqual(
|
||||
expect.objectContaining({
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isNotification: true,
|
||||
hasUnreadCount: false,
|
||||
// The || 1 fallback gives a count of 1 even though no real count exists
|
||||
count: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should update the decoration when a notification state update event fires", () => {
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag: "m.favourite",
|
||||
title: "Favourites",
|
||||
spaceId: "!space:server",
|
||||
onToggleExpanded,
|
||||
});
|
||||
vm.setRooms([room]);
|
||||
|
||||
expect(vm.getSnapshot().notification?.isMention).toBe(false);
|
||||
|
||||
jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true);
|
||||
notificationState.emit(NotificationStateEvents.Update);
|
||||
|
||||
expect(vm.getSnapshot().notification?.isMention).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should unsubscribe from all notification states on dispose", () => {
|
||||
jest.spyOn(notificationState, "off");
|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
+6
@@ -34,6 +34,12 @@
|
||||
.menu {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
/* When a menu is present and revealed on hover, hide the decoration to avoid clutter.
|
||||
Default sections have no menu, so their decoration stays visible on hover. */
|
||||
.notificationDecoration {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
|
||||
+30
@@ -59,6 +59,17 @@ const meta = {
|
||||
isExpanded: true,
|
||||
isFocused: false,
|
||||
isUnread: false,
|
||||
notification: {
|
||||
hasAnyNotificationOrActivity: false,
|
||||
isUnsentMessage: false,
|
||||
invited: false,
|
||||
isMention: false,
|
||||
isActivityNotification: false,
|
||||
isNotification: false,
|
||||
hasUnreadCount: false,
|
||||
count: 0,
|
||||
muted: false,
|
||||
},
|
||||
displaySectionMenu: true,
|
||||
onClick: fn(),
|
||||
onFocus: fn(),
|
||||
@@ -119,3 +130,22 @@ export const Unread: Story = {
|
||||
isUnread: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithNotificationDecoration: Story = {
|
||||
args: {
|
||||
isUnread: true,
|
||||
notification: {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isUnsentMessage: true,
|
||||
invited: true,
|
||||
isMention: true,
|
||||
isActivityNotification: false,
|
||||
isNotification: true,
|
||||
hasUnreadCount: true,
|
||||
count: 12,
|
||||
muted: false,
|
||||
callType: "video",
|
||||
},
|
||||
isExpanded: false,
|
||||
},
|
||||
};
|
||||
|
||||
+12
-1
@@ -19,6 +19,10 @@ import { Flex } from "../../../core/utils/Flex";
|
||||
import { useI18n } from "../../../core/i18n/i18nContext";
|
||||
import { getGroupHeaderAccessibleProps } from "../../../core/VirtualizedList";
|
||||
import { _t } from "../../../core/i18n/i18n";
|
||||
import {
|
||||
NotificationDecoration,
|
||||
type NotificationDecorationData,
|
||||
} from "../RoomListItemWrapper/RoomListItemView/NotificationDecoration";
|
||||
|
||||
/**
|
||||
* The observable state snapshot for a room list section header.
|
||||
@@ -32,6 +36,8 @@ export interface RoomListSectionHeaderViewSnapshot {
|
||||
isExpanded: boolean;
|
||||
/** Whether the section is unread (has any unread rooms) */
|
||||
isUnread: boolean;
|
||||
/** The merged notification decoration aggregating the notifications of the rooms in the section */
|
||||
notification?: NotificationDecorationData;
|
||||
/** Wether to display the section menu */
|
||||
displaySectionMenu: boolean;
|
||||
}
|
||||
@@ -102,7 +108,7 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
|
||||
roomCountInSection,
|
||||
}: Readonly<RoomListSectionHeaderViewProps>): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
const { id, title, isExpanded, isUnread, displaySectionMenu } = useViewModel(vm);
|
||||
const { id, title, isExpanded, isUnread, notification, displaySectionMenu } = useViewModel(vm);
|
||||
const isLastSection = sectionIndex === sectionCount - 1;
|
||||
|
||||
const { ref: droppableRef, isDropTarget } = useDroppable({
|
||||
@@ -177,6 +183,11 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
|
||||
/>
|
||||
<span className={styles.title}>{title}</span>
|
||||
</Flex>
|
||||
{!isExpanded && notification && (
|
||||
<div className={styles.notificationDecoration} aria-hidden={true}>
|
||||
<NotificationDecoration {...notification} />
|
||||
</div>
|
||||
)}
|
||||
{displaySectionMenu && <MenuComponent vm={vm} />}
|
||||
</Flex>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user