From 0409f2d1178ef92bb230e41937c95f7f243da267 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 5 Jun 2026 14:00:44 +0100 Subject: [PATCH 1/7] Make presence icons & colours consistent throughout the app Update WithPresenceIndicator to use the new AvatarPresenceIconView component rather than its own one with old presece icons / colours. --- .../views/avatars/_WithPresenceIndicator.pcss | 30 ++---------------- .../views/avatars/WithPresenceIndicator.tsx | 31 +++++++------------ 2 files changed, 14 insertions(+), 47 deletions(-) diff --git a/apps/web/res/css/views/avatars/_WithPresenceIndicator.pcss b/apps/web/res/css/views/avatars/_WithPresenceIndicator.pcss index 7fe328dc60..9b88d0d1bd 100644 --- a/apps/web/res/css/views/avatars/_WithPresenceIndicator.pcss +++ b/apps/web/res/css/views/avatars/_WithPresenceIndicator.pcss @@ -13,34 +13,10 @@ Please see LICENSE files in the repository root for full details. .mx_WithPresenceIndicator_icon { position: absolute; + /* PresenceIconView has its own idea of where it should be positioned which it probably shouldn't */ + top: initial; + left: initial; right: -2px; bottom: -2px; } - - .mx_WithPresenceIndicator_icon::before { - content: ""; - width: 100%; - height: 100%; - right: 0; - bottom: 0; - position: absolute; - border: 2px solid var(--cpd-color-bg-canvas-default); - border-radius: 50%; - } - - .mx_WithPresenceIndicator_icon_offline::before { - background-color: $presence-offline; - } - - .mx_WithPresenceIndicator_icon_online::before { - background-color: $accent; - } - - .mx_WithPresenceIndicator_icon_away::before { - background-color: $presence-away; - } - - .mx_WithPresenceIndicator_icon_busy::before { - background-color: $presence-busy; - } } diff --git a/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx b/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx index b7997901ac..c6d0d3befa 100644 --- a/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx +++ b/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx @@ -23,22 +23,22 @@ import DMRoomMap from "../../../utils/DMRoomMap"; import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers"; import { useEventEmitter } from "../../../hooks/useEventEmitter"; import { BUSY_PRESENCE_NAME } from "../rooms/PresenceLabel"; +import AvatarPresenceIconView from "../rooms/MemberList/tiles/common/PresenceIconView"; interface Props { room: Room; - size: string; // CSS size - tooltipProps?: { - tabIndex?: number; - }; children: ReactNode; } export enum Presence { - // Note: the names here are used in CSS class names - Online = "ONLINE", - Away = "AWAY", - Offline = "OFFLINE", - Busy = "BUSY", + // This class used to have its own presence indicator and has been + // updated to use the new one so presence colours / icons match across the app. + // These values are the ones from the wire that PresenceIconView expects, + // but really some of the logic here could be deduplicated. + Online = "online", + Away = "unavailable", + Offline = "offline", + Busy = "busy", } function tooltipText(variant: Presence): string { @@ -117,22 +117,13 @@ export const usePresence = (room: Room, member: RoomMember | null): Presence | n return presence; }; -const WithPresenceIndicator: React.FC = ({ room, size, tooltipProps, children }) => { +const WithPresenceIndicator: React.FC = ({ room, children }) => { const dmMember = useDmMember(room); const presence = usePresence(room, dmMember); let icon: JSX.Element | undefined; if (presence) { - icon = ( -
- ); + icon = ; } if (!presence) return <>{children}; From 2fa09e293903ee33b1dc94bb2085a204623c7b96 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 8 Jun 2026 10:09:51 +0100 Subject: [PATCH 2/7] Remove usage of the size prop. You can have it any size you like as long as it's 8px. --- apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx | 2 +- .../components/views/avatars/WithPresenceIndicator-test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx b/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx index d16ed4b8e3..ea26b0350a 100644 --- a/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx +++ b/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx @@ -462,7 +462,7 @@ export default function RoomHeader({ <> - + {/* We hide this from the tabIndex list as it is a pointer shortcut and superfluous for a11y */} {/* Disable on-click actions until the room is created */} { function renderComponent() { return render( - + , ); From d6a028da25a250396af9c1c1f826a4505bac22fc Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 8 Jun 2026 11:23:27 +0100 Subject: [PATCH 3/7] update snapshot --- .../RoomAvatarView-test.tsx.snap | 120 +++++++++--------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/apps/web/test/unit-tests/components/views/avatars/__snapshots__/RoomAvatarView-test.tsx.snap b/apps/web/test/unit-tests/components/views/avatars/__snapshots__/RoomAvatarView-test.tsx.snap index 07a028ff36..b57bc10f45 100644 --- a/apps/web/test/unit-tests/components/views/avatars/__snapshots__/RoomAvatarView-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/avatars/__snapshots__/RoomAvatarView-test.tsx.snap @@ -156,64 +156,7 @@ exports[` should render a video room decoration 1`] = ` `; -exports[` should render the AWAY presence 1`] = ` - -
- - - - - - - - - - - - - -
-
-`; - -exports[` should render the BUSY presence 1`] = ` +exports[` should render the busy presence 1`] = `
should render the BUSY presence 1`] = ` `; -exports[` should render the OFFLINE presence 1`] = ` +exports[` should render the offline presence 1`] = `
should render the OFFLINE presence 1`] = ` `; -exports[` should render the ONLINE presence 1`] = ` +exports[` should render the online presence 1`] = `
should render the ONLINE presence 1`] = `
`; + +exports[` should render the unavailable presence 1`] = ` + +
+ + + + + + + + + + + + + +
+
+`; From 107289a16c0aa9a1cc1acb2608621cc78be5ffd1 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 8 Jun 2026 17:55:32 +0100 Subject: [PATCH 4/7] Put tooltips on presence consistently Sort out duplication between presence icon code, have the MemberIconView position the presence icon rather than the presence icon trying to position itself. --- .../views/avatars/_WithPresenceIndicator.pcss | 7 ++--- .../res/css/views/rooms/_MemberTileView.pcss | 6 ++++ .../css/views/rooms/_PresenceIconView.pcss | 5 ---- .../views/avatars/WithPresenceIndicator.tsx | 21 ++------------ .../tiles/common/MemberTileView.tsx | 3 +- .../tiles/common/PresenceIconView.tsx | 28 +++++++++++++++++-- 6 files changed, 38 insertions(+), 32 deletions(-) diff --git a/apps/web/res/css/views/avatars/_WithPresenceIndicator.pcss b/apps/web/res/css/views/avatars/_WithPresenceIndicator.pcss index 9b88d0d1bd..68a0f0dbca 100644 --- a/apps/web/res/css/views/avatars/_WithPresenceIndicator.pcss +++ b/apps/web/res/css/views/avatars/_WithPresenceIndicator.pcss @@ -13,10 +13,7 @@ Please see LICENSE files in the repository root for full details. .mx_WithPresenceIndicator_icon { position: absolute; - /* PresenceIconView has its own idea of where it should be positioned which it probably shouldn't */ - top: initial; - left: initial; - right: -2px; - bottom: -2px; + right: 0px; + bottom: 2px; } } diff --git a/apps/web/res/css/views/rooms/_MemberTileView.pcss b/apps/web/res/css/views/rooms/_MemberTileView.pcss index 307625d042..ce28cbdfb8 100644 --- a/apps/web/res/css/views/rooms/_MemberTileView.pcss +++ b/apps/web/res/css/views/rooms/_MemberTileView.pcss @@ -41,4 +41,10 @@ Please see LICENSE files in the repository root for full details. height: 32px; width: 32px; } + + .mx_MemberTileView_presence { + position: absolute; + right: -2px; + bottom: 2px; + } } diff --git a/apps/web/res/css/views/rooms/_PresenceIconView.pcss b/apps/web/res/css/views/rooms/_PresenceIconView.pcss index e09fbdf2fa..585baf1928 100644 --- a/apps/web/res/css/views/rooms/_PresenceIconView.pcss +++ b/apps/web/res/css/views/rooms/_PresenceIconView.pcss @@ -6,11 +6,6 @@ Please see LICENSE files in the repository root for full details. */ .mx_PresenceIconView { - position: absolute; - top: 24px; - left: 24px; - width: 12px; - height: 12px; display: flex; justify-content: center; align-items: center; diff --git a/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx b/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx index c6d0d3befa..92b93adb4e 100644 --- a/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx +++ b/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx @@ -15,10 +15,8 @@ import { type User, UserEvent, } from "matrix-js-sdk/src/matrix"; -import { Tooltip } from "@vector-im/compound-web"; import { isPresenceEnabled } from "../../../utils/presence"; -import { _t } from "../../../languageHandler"; import DMRoomMap from "../../../utils/DMRoomMap"; import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers"; import { useEventEmitter } from "../../../hooks/useEventEmitter"; @@ -41,19 +39,6 @@ export enum Presence { Busy = "busy", } -function tooltipText(variant: Presence): string { - switch (variant) { - case Presence.Online: - return _t("presence|online"); - case Presence.Away: - return _t("presence|away"); - case Presence.Offline: - return _t("presence|offline"); - case Presence.Busy: - return _t("presence|busy"); - } -} - function getDmMember(room: Room): RoomMember | null { const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); return otherUserId ? room.getMember(otherUserId) : null; @@ -123,7 +108,7 @@ const WithPresenceIndicator: React.FC = ({ room, children }) => { let icon: JSX.Element | undefined; if (presence) { - icon = ; + icon = ; } if (!presence) return <>{children}; @@ -131,9 +116,7 @@ const WithPresenceIndicator: React.FC = ({ room, children }) => { return (
{children} - - {icon} - +
{icon}
); }; diff --git a/apps/web/src/components/views/rooms/MemberList/tiles/common/MemberTileView.tsx b/apps/web/src/components/views/rooms/MemberList/tiles/common/MemberTileView.tsx index cb0cec74d9..fad0b3f0da 100644 --- a/apps/web/src/components/views/rooms/MemberList/tiles/common/MemberTileView.tsx +++ b/apps/web/src/components/views/rooms/MemberList/tiles/common/MemberTileView.tsx @@ -51,7 +51,8 @@ export function MemberTileView(props: Props): JSX.Element { >
- {props.avatarJsx} {props.presenceJsx} + {props.avatarJsx} +
{props.presenceJsx}
{props.nameJsx}
diff --git a/apps/web/src/components/views/rooms/MemberList/tiles/common/PresenceIconView.tsx b/apps/web/src/components/views/rooms/MemberList/tiles/common/PresenceIconView.tsx index ac31084adf..8b67418536 100644 --- a/apps/web/src/components/views/rooms/MemberList/tiles/common/PresenceIconView.tsx +++ b/apps/web/src/components/views/rooms/MemberList/tiles/common/PresenceIconView.tsx @@ -6,12 +6,15 @@ Please see LICENSE files in the repository root for full details. */ import React, { type JSX } from "react"; +import classNames from "classnames"; 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 DNDIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8"; -import classNames from "classnames"; +import { Tooltip } from "@vector-im/compound-web"; import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; +import { _t } from "../../../../../../languageHandler"; + interface Props { className?: string; presenceState: string; @@ -36,9 +39,30 @@ function getIconForPresenceState(state: string): JSX.Element { } } +function getTooltipText(state: string): string { + switch (state) { + case "online": + return _t("presence|online"); + case "offline": + return _t("presence|offline"); + case "unavailable": + case "io.element.unreachable": + return _t("presence|away"); + case BUSY_PRESENCE_NAME.name: + case BUSY_PRESENCE_NAME.altName: + return _t("presence|busy"); + default: + throw new Error(`Presence state "${state}" is unknown.`); + } +} + const AvatarPresenceIconView: React.FC = ({ className, presenceState }) => { const names = classNames("mx_PresenceIconView", className); - return
{getIconForPresenceState(presenceState)}
; + return ( + +
{getIconForPresenceState(presenceState)}
+
+ ); }; export default AvatarPresenceIconView; From 07974c7747a2660d3e161872d742a345d156d678 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 8 Jun 2026 18:14:31 +0100 Subject: [PATCH 5/7] Move tooltip test up to PresenceIconView --- .../avatars/WithPresenceIndicator-test.tsx | 14 +- .../WithPresenceIndicator-test.tsx.snap | 128 ++++++++++++++++-- .../memberlist/PresenceIconView-test.tsx | 23 +++- .../PresenceIconView-test.tsx.snap | 41 ++++++ 4 files changed, 177 insertions(+), 29 deletions(-) diff --git a/apps/web/test/unit-tests/components/views/avatars/WithPresenceIndicator-test.tsx b/apps/web/test/unit-tests/components/views/avatars/WithPresenceIndicator-test.tsx index 0583f728d2..20ef1799ad 100644 --- a/apps/web/test/unit-tests/components/views/avatars/WithPresenceIndicator-test.tsx +++ b/apps/web/test/unit-tests/components/views/avatars/WithPresenceIndicator-test.tsx @@ -83,19 +83,7 @@ describe("WithPresenceIndicator", () => { return member; }); - const { container, asFragment } = renderComponent(); - - const presence = container.querySelector(".mx_WithPresenceIndicator_icon")!; - expect(presence).toBeVisible(); - await userEvent.hover(presence!); - - // wait for the tooltip to open - const tooltip = await waitFor(() => { - const tooltip = document.getElementById(presence.getAttribute("aria-labelledby")!); - expect(tooltip).toBeVisible(); - return tooltip; - }); - expect(tooltip).toHaveTextContent(renderedStr); + const { asFragment } = renderComponent(); expect(asFragment()).toMatchSnapshot(); }); diff --git a/apps/web/test/unit-tests/components/views/avatars/__snapshots__/WithPresenceIndicator-test.tsx.snap b/apps/web/test/unit-tests/components/views/avatars/__snapshots__/WithPresenceIndicator-test.tsx.snap index ac7ebee2be..46f56e3a7a 100644 --- a/apps/web/test/unit-tests/components/views/avatars/__snapshots__/WithPresenceIndicator-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/avatars/__snapshots__/WithPresenceIndicator-test.tsx.snap @@ -7,11 +7,43 @@ exports[`WithPresenceIndicator renders presence indicator with tooltip for DM ro >
+ class="mx_WithPresenceIndicator_icon" + > + +
+ + + + + + + + + + +
+
+
`; @@ -23,11 +55,45 @@ exports[`WithPresenceIndicator renders presence indicator with tooltip for DM ro >
+ class="mx_WithPresenceIndicator_icon" + > + +
+ + + + + + + + + + +
+
+
`; @@ -39,11 +105,43 @@ exports[`WithPresenceIndicator renders presence indicator with tooltip for DM ro >
+ class="mx_WithPresenceIndicator_icon" + > + +
+ + + + + + + + + + +
+
+
`; diff --git a/apps/web/test/unit-tests/components/views/rooms/memberlist/PresenceIconView-test.tsx b/apps/web/test/unit-tests/components/views/rooms/memberlist/PresenceIconView-test.tsx index 74c23f2199..4c9714a782 100644 --- a/apps/web/test/unit-tests/components/views/rooms/memberlist/PresenceIconView-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/memberlist/PresenceIconView-test.tsx @@ -7,7 +7,8 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { render } from "jest-matrix-react"; +import { render, waitFor } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; import AvatarPresenceIconView from "../../../../../../src/components/views/rooms/MemberList/tiles/common/PresenceIconView"; @@ -39,4 +40,24 @@ describe("", () => { expect(container.querySelector(".mx_PresenceIconView_dnd")).toBeDefined(); expect(container).toMatchSnapshot(); }); + + it("renders the tooltip", async () => { + const user = userEvent.setup(); + + const { container, asFragment } = render(); + + const presence = container.querySelector(".mx_PresenceIconView")!; + expect(presence).toBeVisible(); + await user.hover(presence!); + + // wait for the tooltip to open + const tooltip = await waitFor(() => { + const tooltip = document.getElementById(presence.getAttribute("aria-labelledby")!); + expect(tooltip).toBeVisible(); + return tooltip; + }); + expect(tooltip).toHaveTextContent("Busy"); + + expect(asFragment()).toMatchSnapshot(); + }); }); diff --git a/apps/web/test/unit-tests/components/views/rooms/memberlist/__snapshots__/PresenceIconView-test.tsx.snap b/apps/web/test/unit-tests/components/views/rooms/memberlist/__snapshots__/PresenceIconView-test.tsx.snap index a2be761ec7..87159b7d1e 100644 --- a/apps/web/test/unit-tests/components/views/rooms/memberlist/__snapshots__/PresenceIconView-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/rooms/memberlist/__snapshots__/PresenceIconView-test.tsx.snap @@ -173,3 +173,44 @@ exports[` renders correctly for presence=unavailable/unreacha `; + +exports[` renders the tooltip 1`] = ` + + +
+ + + + + + + + + + +
+
+
+`; From 6a407291372aba69f1d3e4029211e171ce3ed5d6 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 8 Jun 2026 19:08:50 +0100 Subject: [PATCH 6/7] Add tests for usePresence and move the slightly weird test about it working when no member is linked onto the hook test which is what it's actually trying to test. --- .../avatars/WithPresenceIndicator-test.tsx | 172 +++++++++++++----- 1 file changed, 130 insertions(+), 42 deletions(-) diff --git a/apps/web/test/unit-tests/components/views/avatars/WithPresenceIndicator-test.tsx b/apps/web/test/unit-tests/components/views/avatars/WithPresenceIndicator-test.tsx index 20ef1799ad..b3871caa8f 100644 --- a/apps/web/test/unit-tests/components/views/avatars/WithPresenceIndicator-test.tsx +++ b/apps/web/test/unit-tests/components/views/avatars/WithPresenceIndicator-test.tsx @@ -6,17 +6,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { render, waitFor } from "jest-matrix-react"; +import { act, render, renderHook, waitFor } from "jest-matrix-react"; import { mocked } from "jest-mock"; -import { type MatrixClient, PendingEventOrdering, Room, RoomMember, User } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient, PendingEventOrdering, Room, RoomMember, User, UserEvent } from "matrix-js-sdk/src/matrix"; import React from "react"; -import userEvent from "@testing-library/user-event"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import { stubClient } from "../../../../test-utils"; +import { getMockClientWithEventEmitter, stubClient } from "../../../../test-utils"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; -import WithPresenceIndicator from "../../../../../src/components/views/avatars/WithPresenceIndicator"; +import WithPresenceIndicator, { + Presence, + usePresence, +} from "../../../../../src/components/views/avatars/WithPresenceIndicator"; import { isPresenceEnabled } from "../../../../../src/utils/presence"; +import { getJoinedNonFunctionalMembers } from "../../../../../src/utils/room/getJoinedNonFunctionalMembers"; jest.mock("../../../../../src/utils/presence"); @@ -87,51 +90,136 @@ describe("WithPresenceIndicator", () => { expect(asFragment()).toMatchSnapshot(); }); +}); + +describe("usePresence", () => { + const ROOM_ID = "roomId"; + const DM_USER_ID = "@bob:foo.bar"; + + let mockClient: ReturnType; + let room: Room; + let member: RoomMember; + let user: User; + + beforeEach(() => { + mockClient = getMockClientWithEventEmitter({ + getUserId: jest.fn().mockReturnValue("@alice:foo.bar"), + getUser: jest.fn().mockReturnValue(null), + store: { getPendingEvents: jest.fn().mockResolvedValue([]) }, + }); + room = new Room(ROOM_ID, mockClient as unknown as MatrixClient, mockClient.getUserId() ?? ""); + + mocked(isPresenceEnabled).mockReturnValue(true); + mocked(getJoinedNonFunctionalMembers).mockReturnValue([1, 2] as any); + + user = new User(DM_USER_ID); + user.presence = "online"; + member = new RoomMember(ROOM_ID, DM_USER_ID); + member.user = user; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns null when presence is disabled", () => { + mocked(isPresenceEnabled).mockReturnValue(false); + const { result } = renderHook(() => usePresence(room, member)); + expect(result.current).toBeNull(); + }); + + it("returns null when room does not have exactly 2 members", () => { + mocked(getJoinedNonFunctionalMembers).mockReturnValue([1] as any); + const { result } = renderHook(() => usePresence(room, member)); + expect(result.current).toBeNull(); + }); + + it("returns null when member is null", () => { + const { result } = renderHook(() => usePresence(room, null)); + expect(result.current).toBeNull(); + }); it.each([ - ["online", "Online"], - ["offline", "Offline"], - ["unavailable", "Away"], - ])( - "renders presence indicator when member.user is not linked but client has user data", - async (presenceStr, renderedStr) => { - mocked(isPresenceEnabled).mockReturnValue(true); + ["online", Presence.Online], + ["offline", Presence.Offline], + ["unavailable", Presence.Away], + ["busy", Presence.Busy], + ])("returns correct presence for user with '%s' presence state", (presenceStr, expectedPresence) => { + user.presence = presenceStr; + const { result } = renderHook(() => usePresence(room, member)); + expect(result.current).toBe(expectedPresence); + }); - const DM_USER_ID = "@bob:foo.bar"; - const dmRoomMap = { - getUserIdForRoomId: () => { - return DM_USER_ID; - }, - } as unknown as DMRoomMap; + it("returns Online when user.currentlyActive is true regardless of presence string", () => { + user.presence = "offline"; + user.currentlyActive = true; + const { result } = renderHook(() => usePresence(room, member)); + expect(result.current).toBe(Presence.Online); + }); - jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + it("updates when UserEvent.Presence fires on member.user", async () => { + user.presence = "online"; + const { result } = renderHook(() => usePresence(room, member)); + expect(result.current).toBe(Presence.Online); - // member.user is not set: simulates the race condition on fresh login with no cache - // where the room list renders before member.user is linked - room.getMember = jest.fn((userId) => { - return new RoomMember(room.roomId, userId); - }); + act(() => { + user.presence = "offline"; + user.emit(UserEvent.Presence, null as any, user); + }); - // But client.getUser() has the presence data - const user = new User(DM_USER_ID); - user.presence = presenceStr; - mockClient.getUser = jest.fn((userId) => (userId === DM_USER_ID ? user : null)); + await waitFor(() => expect(result.current).toBe(Presence.Offline)); + }); - const { container } = renderComponent(); + it("updates when UserEvent.CurrentlyActive fires on member.user", async () => { + user.presence = "offline"; + user.currentlyActive = false; + const { result } = renderHook(() => usePresence(room, member)); + expect(result.current).toBe(Presence.Offline); - const presence = container.querySelector(".mx_WithPresenceIndicator_icon")!; - expect(presence).toBeVisible(); - await userEvent.hover(presence!); + act(() => { + user.currentlyActive = true; + user.emit(UserEvent.CurrentlyActive, null as any, user); + }); - const tooltip = await waitFor(() => { - const tooltip = document.getElementById(presence.getAttribute("aria-labelledby")!); - expect(tooltip).toBeVisible(); - return tooltip; - }); + await waitFor(() => expect(result.current).toBe(Presence.Online)); + }); - // component should fall back to reading client.getUser() which does have the presence data - // so it should render correctly - expect(tooltip).toHaveTextContent(renderedStr); - }, - ); + it("returns correct presence when member.user is not linked but client has user data", () => { + member.user = undefined; + mocked(mockClient.getUser).mockImplementation((userId) => (userId === DM_USER_ID ? user : null)); + + user.presence = "online"; + const { result } = renderHook(() => usePresence(room, member)); + expect(result.current).toBe("online"); + }); + + it("updates via client-level UserEvent.Presence when member.user is not yet linked", async () => { + member.user = undefined; + mocked(mockClient.getUser).mockImplementation((userId) => (userId === DM_USER_ID ? user : null)); + user.presence = "online"; + + const { result } = renderHook(() => usePresence(room, member)); + expect(result.current).toBe(Presence.Online); + + act(() => { + user.presence = "offline"; + mockClient.emit(UserEvent.Presence, null as any, user); + }); + + await waitFor(() => expect(result.current).toBe(Presence.Offline)); + }); + + it("does not update when client emits UserEvent.Presence for a different user", async () => { + user.presence = "online"; + const { result } = renderHook(() => usePresence(room, member)); + expect(result.current).toBe(Presence.Online); + + act(() => { + const otherUser = new User("@other:foo.bar"); + otherUser.presence = "offline"; + mockClient.emit(UserEvent.Presence, null as any, otherUser); + }); + + expect(result.current).toBe(Presence.Online); + }); }); From adedbe3206f1cfbeb0a6c7b897fb719e0ca74b12 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 9 Jun 2026 11:10:16 +0100 Subject: [PATCH 7/7] snapshots --- .../MemberTileView-test.tsx.snap | 16 +- .../PresenceIconView-test.tsx.snap | 263 ++++++++++-------- 2 files changed, 156 insertions(+), 123 deletions(-) diff --git a/apps/web/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap b/apps/web/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap index 0c5b095ba1..22532ebea9 100644 --- a/apps/web/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap @@ -29,7 +29,9 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh > u - +
u - +
u - +
F - +
renders correctly for presence=busy 1`] = `
-
- - - - - - - - - -
+ + + + + + + +
+
`; exports[` renders correctly for presence=offline 1`] = `
-
- - - - - - - - - -
+ + + + + + + +
+
`; exports[` renders correctly for presence=online 1`] = `
-
- - - - - - - - - -
+ + + + + + + +
+
`; exports[` renders correctly for presence=unavailable/unreachable 1`] = `
-
- - - - - - - - - -
+ + + + + + + +
+
`; exports[` renders correctly for presence=unavailable/unreachable 2`] = `
-
- - - - - - - - - -
+ + + + + + + +
+ `;