diff --git a/apps/web/res/css/views/avatars/_WithPresenceIndicator.pcss b/apps/web/res/css/views/avatars/_WithPresenceIndicator.pcss index 7fe328dc60..68a0f0dbca 100644 --- a/apps/web/res/css/views/avatars/_WithPresenceIndicator.pcss +++ b/apps/web/res/css/views/avatars/_WithPresenceIndicator.pcss @@ -13,34 +13,7 @@ Please see LICENSE files in the repository root for full details. .mx_WithPresenceIndicator_icon { position: absolute; - 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; + 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 b7997901ac..92b93adb4e 100644 --- a/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx +++ b/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx @@ -15,43 +15,28 @@ 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"; 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", -} - -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"); - } + // 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 getDmMember(room: Room): RoomMember | null { @@ -117,22 +102,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}; @@ -140,9 +116,7 @@ const WithPresenceIndicator: React.FC = ({ room, size, tooltipProps, chil 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; 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( - + , ); @@ -83,67 +86,140 @@ 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(); }); +}); + +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); + }); }); 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`] = ` + +
+ + + + + + + + + + + + + +
+
+`; 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__/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`] = `
-
- - - - - - - - - -
+ + + + + + + +
+ `; + +exports[` renders the tooltip 1`] = ` + + +
+ + + + + + + + + + +
+
+
+`;