diff --git a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts
index b0b32a765a..73eb98512b 100644
--- a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts
+++ b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts
@@ -255,6 +255,28 @@ test.describe("Room list", () => {
await expect(publicRoom).toMatchScreenshot("room-list-item-public.png");
});
+ test("should be a low priority room", { tag: "@screenshot" }, async ({ page, app, user }) => {
+ // @ts-ignore Visibility enum is not accessible
+ await app.client.createRoom({ name: "low priority room", visibility: "public" });
+ const roomListView = getRoomList(page);
+ const publicRoom = roomListView.getByRole("gridcell", { name: "low priority room" });
+
+ // Make room low priority
+ await publicRoom.hover();
+ const roomItemMenu = publicRoom.getByRole("button", { name: "More Options" });
+ await roomItemMenu.click();
+ await page.getByRole("menuitemcheckbox", { name: "Low priority" }).click();
+
+ // Should have low priority decoration
+ await expect(publicRoom.locator(".mx_RoomAvatarView_icon")).toHaveAccessibleName(
+ "This is a low priority room",
+ );
+
+ // focus the user menu to avoid to have hover decoration
+ await page.getByRole("button", { name: "User menu" }).focus();
+ await expect(publicRoom).toMatchScreenshot("room-list-item-low-priority.png");
+ });
+
test("should be a video room", { tag: "@screenshot" }, async ({ page, app, user }) => {
await page.getByTestId("room-list-panel").getByRole("button", { name: "Add" }).click();
await page.getByRole("menuitem", { name: "New video room" }).click();
diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-low-priority-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-low-priority-linux.png
new file mode 100644
index 0000000000..cc2adcc598
Binary files /dev/null and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-low-priority-linux.png differ
diff --git a/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx b/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx
index 8879f5ae69..3832616a9c 100644
--- a/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx
+++ b/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx
@@ -10,26 +10,26 @@ import { useEffect, useState } from "react";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { useDmMember, usePresence, type Presence } from "../../views/avatars/WithPresenceIndicator";
+import { DefaultTagID } from "../../../stores/room-list/models";
+
+export enum AvatarBadgeDecoration {
+ LowPriority = "LowPriority",
+ VideoRoom = "VideoRoom",
+ PublicRoom = "PublicRoom",
+ Presence = "Presence",
+}
export interface RoomAvatarViewState {
- /**
- * Whether the room avatar has a decoration.
- * A decoration can be a public or a video call icon or an indicator of presence.
- */
- hasDecoration: boolean;
- /**
- * Whether the room is public.
- */
- isPublic: boolean;
- /**
- * Whether the room is a video room.
- */
- isVideoRoom: boolean;
/**
* The presence of the user in the DM room.
* If null, the user is not in a DM room or presence is not enabled.
*/
presence: Presence | null;
+
+ /**
+ * The decoration that should be rendered.
+ */
+ badgeDecoration?: AvatarBadgeDecoration;
}
/**
@@ -41,10 +41,20 @@ export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState {
const roomMember = useDmMember(room);
const presence = usePresence(room, roomMember);
const isPublic = useIsPublic(room);
+ const isLowPriority = !!room.tags[DefaultTagID.LowPriority];
- const hasDecoration = isPublic || isVideoRoom || presence !== null;
+ let badgeDecoration: AvatarBadgeDecoration | undefined;
+ if (isLowPriority) {
+ badgeDecoration = AvatarBadgeDecoration.LowPriority;
+ } else if (isVideoRoom) {
+ badgeDecoration = AvatarBadgeDecoration.VideoRoom;
+ } else if (isPublic) {
+ badgeDecoration = AvatarBadgeDecoration.PublicRoom;
+ } else if (presence) {
+ badgeDecoration = AvatarBadgeDecoration.Presence;
+ }
- return { hasDecoration, isPublic, isVideoRoom, presence };
+ return { badgeDecoration, presence };
}
/**
diff --git a/src/components/views/avatars/RoomAvatarView.tsx b/src/components/views/avatars/RoomAvatarView.tsx
index 8810d073c5..5ddf355d6f 100644
--- a/src/components/views/avatars/RoomAvatarView.tsx
+++ b/src/components/views/avatars/RoomAvatarView.tsx
@@ -9,13 +9,14 @@ import React, { type JSX } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
import VideoIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
+import ArrowDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/arrow-down";
import OnlineOrUnavailableIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-solid-8x8";
import OfflineIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-outline-8x8";
import BusyIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8";
import classNames from "classnames";
import RoomAvatar from "./RoomAvatar";
-import { useRoomAvatarViewModel } from "../../viewmodels/avatars/RoomAvatarViewModel";
+import { AvatarBadgeDecoration, useRoomAvatarViewModel } from "../../viewmodels/avatars/RoomAvatarViewModel";
import { _t } from "../../../languageHandler";
import { Presence } from "./WithPresenceIndicator";
@@ -33,41 +34,21 @@ interface RoomAvatarViewProps {
export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
const vm = useRoomAvatarViewModel(room);
// No decoration, we just show the avatar
- if (!vm.hasDecoration) return ;
+ if (!vm.badgeDecoration) return ;
+
+ const icon = getAvatarDecoration(vm.badgeDecoration, vm.presence);
+
+ // Presence indicator and video/public icons don't have the same size
+ // We use different masks
+ const maskClass =
+ vm.badgeDecoration === AvatarBadgeDecoration.Presence
+ ? "mx_RoomAvatarView_RoomAvatar_presence"
+ : "mx_RoomAvatarView_RoomAvatar_icon";
return (
-
-
- {/* If the room is a public video room, we prefer to display only the video icon */}
- {vm.isPublic && !vm.isVideoRoom && (
-
- )}
- {vm.isVideoRoom && (
-
- )}
- {vm.presence && }
+
+ {icon}
);
}
@@ -126,3 +107,39 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
);
}
}
+
+function getAvatarDecoration(decoration: AvatarBadgeDecoration, presence: Presence | null): React.ReactNode {
+ if (decoration === AvatarBadgeDecoration.LowPriority) {
+ return (
+
+ );
+ } else if (decoration === AvatarBadgeDecoration.VideoRoom) {
+ return (
+
+ );
+ } else if (decoration === AvatarBadgeDecoration.PublicRoom) {
+ return (
+
+ );
+ } else if (decoration === AvatarBadgeDecoration.Presence) {
+ return ;
+ }
+}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 0316615c72..3a55fdb75f 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -2047,6 +2047,7 @@
"read_topic": "Click to read topic",
"rejecting": "Rejecting inviteā¦",
"rejoin_button": "Re-join",
+ "room_is_low_priority": "This is a low priority room",
"search": {
"all_rooms_button": "Search all rooms",
"placeholder": "Search messagesā¦",
diff --git a/test/unit-tests/components/viewmodels/avatars/RoomAvatarViewModel-test.tsx b/test/unit-tests/components/viewmodels/avatars/RoomAvatarViewModel-test.tsx
index ffaa152a53..5f29f3ff33 100644
--- a/test/unit-tests/components/viewmodels/avatars/RoomAvatarViewModel-test.tsx
+++ b/test/unit-tests/components/viewmodels/avatars/RoomAvatarViewModel-test.tsx
@@ -8,10 +8,14 @@
import { renderHook, waitFor } from "jest-matrix-react";
import { JoinRule, type MatrixClient, type Room, RoomMember, User } from "matrix-js-sdk/src/matrix";
-import { useRoomAvatarViewModel } from "../../../../../src/components/viewmodels/avatars/RoomAvatarViewModel";
+import {
+ AvatarBadgeDecoration,
+ useRoomAvatarViewModel,
+} from "../../../../../src/components/viewmodels/avatars/RoomAvatarViewModel";
import { createTestClient, mkStubRoom } from "../../../../test-utils";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import * as PresenceIndicatorModule from "../../../../../src/components/views/avatars/WithPresenceIndicator";
+import { DefaultTagID } from "../../../../../src/stores/room-list/models";
jest.mock("../../../../../src/utils/room/getJoinedNonFunctionalMembers", () => ({
getJoinedNonFunctionalMembers: jest.fn().mockReturnValue([]),
@@ -32,35 +36,61 @@ describe("RoomAvatarViewModel", () => {
jest.spyOn(PresenceIndicatorModule, "usePresence").mockReturnValue(null);
});
- it("should has hasDecoration to false", async () => {
+ it("should have badgeDecoration set to LowPriority", () => {
+ room.tags[DefaultTagID.LowPriority] = {};
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
- expect(vm.current.hasDecoration).toBe(false);
+ expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.LowPriority);
});
- it("should has isVideoRoom set to true", () => {
+ it("should have badgeDecoration set to VideoRoom", () => {
jest.spyOn(room, "isCallRoom").mockReturnValue(true);
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
- expect(vm.current.isVideoRoom).toBe(true);
- expect(vm.current.hasDecoration).toBe(true);
+ expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.VideoRoom);
});
- it("should has isPublic set to true", () => {
+ it("should have badgeDecoration set to PublicRoom", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
-
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
- expect(vm.current.isPublic).toBe(true);
- expect(vm.current.hasDecoration).toBe(true);
+ expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.PublicRoom);
+ });
+
+ it("should set badgeDecoration based on priority", () => {
+ // 1. Presence has the least priority
+ const user = User.createUser("userId", matrixClient);
+ const roomMember = new RoomMember(room.roomId, "userId");
+ roomMember.user = user;
+ jest.spyOn(PresenceIndicatorModule, "useDmMember").mockReturnValue(roomMember);
+ jest.spyOn(PresenceIndicatorModule, "usePresence").mockReturnValue(PresenceIndicatorModule.Presence.Online);
+
+ const { result: vm1 } = renderHook(() => useRoomAvatarViewModel(room));
+ expect(vm1.current.badgeDecoration).toBe(AvatarBadgeDecoration.Presence);
+
+ // 2. With presence and public room, presence takes precedence
+ jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public);
+ // Render again, it's easier than mocking the event emitter.
+ const { result: vm, rerender } = renderHook(() => useRoomAvatarViewModel(room));
+ expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.PublicRoom);
+
+ // 3. With presence, public-room and video room, video room takes precedence
+ jest.spyOn(room, "isCallRoom").mockReturnValue(true);
+ rerender(room);
+ expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.VideoRoom);
+
+ // 4. With presence, public room, video room and low priority, low priority takes precedence
+ room.tags[DefaultTagID.LowPriority] = {};
+ rerender(room);
+ expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.LowPriority);
});
it("should recompute isPublic when room changed", async () => {
const { result: vm, rerender } = renderHook((props) => useRoomAvatarViewModel(props), { initialProps: room });
- expect(vm.current.isPublic).toBe(false);
+ expect(vm.current.badgeDecoration).not.toBe(AvatarBadgeDecoration.PublicRoom);
const publicRoom = mkStubRoom("roomId2", "roomName2", matrixClient);
jest.spyOn(publicRoom, "getJoinRule").mockReturnValue(JoinRule.Public);
rerender(publicRoom);
- await waitFor(() => expect(vm.current.isPublic).toBe(true));
+ await waitFor(() => expect(vm.current.badgeDecoration).toBe(AvatarBadgeDecoration.PublicRoom));
});
it("should return presence", async () => {
diff --git a/test/unit-tests/components/views/avatars/RoomAvatarView-test.tsx b/test/unit-tests/components/views/avatars/RoomAvatarView-test.tsx
index 020b92227d..00ab886b4f 100644
--- a/test/unit-tests/components/views/avatars/RoomAvatarView-test.tsx
+++ b/test/unit-tests/components/views/avatars/RoomAvatarView-test.tsx
@@ -12,6 +12,7 @@ import { mocked } from "jest-mock";
import { RoomAvatarView } from "../../../../../src/components/views/avatars/RoomAvatarView";
import { mkStubRoom, stubClient } from "../../../../test-utils";
import {
+ AvatarBadgeDecoration,
type RoomAvatarViewState,
useRoomAvatarViewModel,
} from "../../../../../src/components/viewmodels/avatars/RoomAvatarViewModel";
@@ -19,6 +20,7 @@ import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { Presence } from "../../../../../src/components/views/avatars/WithPresenceIndicator";
jest.mock("../../../../../src/components/viewmodels/avatars/RoomAvatarViewModel", () => ({
+ ...jest.requireActual("../../../../../src/components/viewmodels/avatars/RoomAvatarViewModel"),
useRoomAvatarViewModel: jest.fn(),
}));
@@ -33,9 +35,7 @@ describe("", () => {
beforeEach(() => {
defaultValue = {
- hasDecoration: true,
- isPublic: true,
- isVideoRoom: true,
+ badgeDecoration: undefined,
presence: null,
};
@@ -43,13 +43,27 @@ describe("", () => {
});
it("should not render a decoration", () => {
- mocked(useRoomAvatarViewModel).mockReturnValue({ ...defaultValue, hasDecoration: false });
+ mocked(useRoomAvatarViewModel).mockReturnValue({ ...defaultValue });
const { asFragment } = render();
expect(asFragment()).toMatchSnapshot();
});
+ it("should render a low priority room decoration", () => {
+ mocked(useRoomAvatarViewModel).mockReturnValue({
+ ...defaultValue,
+ badgeDecoration: AvatarBadgeDecoration.LowPriority,
+ });
+ const { asFragment } = render();
+
+ expect(screen.getByLabelText("This is a low priority room")).toBeInTheDocument();
+ expect(asFragment()).toMatchSnapshot();
+ });
+
it("should render a video room decoration", () => {
- mocked(useRoomAvatarViewModel).mockReturnValue({ ...defaultValue, hasDecoration: true, isVideoRoom: true });
+ mocked(useRoomAvatarViewModel).mockReturnValue({
+ ...defaultValue,
+ badgeDecoration: AvatarBadgeDecoration.VideoRoom,
+ });
const { asFragment } = render();
expect(screen.getByLabelText("This room is a video room")).toBeInTheDocument();
@@ -59,9 +73,7 @@ describe("", () => {
it("should render a public room decoration", () => {
mocked(useRoomAvatarViewModel).mockReturnValue({
...defaultValue,
- hasDecoration: true,
- isPublic: true,
- isVideoRoom: false,
+ badgeDecoration: AvatarBadgeDecoration.PublicRoom,
});
const { asFragment } = render();
@@ -69,19 +81,6 @@ describe("", () => {
expect(asFragment()).toMatchSnapshot();
});
- it("should not render a public room decoration if the room is a video room", () => {
- mocked(useRoomAvatarViewModel).mockReturnValue({
- ...defaultValue,
- hasDecoration: true,
- isPublic: true,
- isVideoRoom: true,
- });
- render();
-
- expect(screen.getByLabelText("This room is a video room")).toBeInTheDocument();
- expect(screen.queryByLabelText("This room is public")).toBeNull();
- });
-
it.each([
{ presence: Presence.Online, label: "Online" },
{ presence: Presence.Offline, label: "Offline" },
@@ -90,7 +89,7 @@ describe("", () => {
])("should render the $presence presence", ({ presence, label }) => {
mocked(useRoomAvatarViewModel).mockReturnValue({
...defaultValue,
- hasDecoration: true,
+ badgeDecoration: AvatarBadgeDecoration.Presence,
presence,
});
const { asFragment } = render();
diff --git a/test/unit-tests/components/views/avatars/__snapshots__/RoomAvatarView-test.tsx.snap b/test/unit-tests/components/views/avatars/__snapshots__/RoomAvatarView-test.tsx.snap
index 1a4263a0c9..9aeb54ab0f 100644
--- a/test/unit-tests/components/views/avatars/__snapshots__/RoomAvatarView-test.tsx.snap
+++ b/test/unit-tests/components/views/avatars/__snapshots__/RoomAvatarView-test.tsx.snap
@@ -24,6 +24,48 @@ exports[` should not render a decoration 1`] = `
`;
+exports[` should render a low priority room decoration 1`] = `
+
+
+
+
+
+
+
+
+`;
+
exports[` should render a public room decoration 1`] = `
should render the AWAY presence 1`] = `
>
should render the AWAY presence 1`] = `
width="32px"
/>
-
should render the BUSY presence 1`] = `
>
should render the BUSY presence 1`] = `
width="32px"
/>
-
should render the OFFLINE presence 1`] = `
>
should render the OFFLINE presence 1`] = `
width="32px"
/>
-
should render the ONLINE presence 1`] = `
>
should render the ONLINE presence 1`] = `
width="32px"
/>
-