diff --git a/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx b/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx index 4c4caecd693..b7997901ac4 100644 --- a/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx +++ b/apps/web/src/components/views/avatars/WithPresenceIndicator.tsx @@ -7,7 +7,14 @@ Please see LICENSE files in the repository root for full details. */ import React, { type JSX, type ReactNode, useEffect, useState } from "react"; -import { ClientEvent, type Room, type RoomMember, RoomStateEvent, UserEvent } from "matrix-js-sdk/src/matrix"; +import { + ClientEvent, + type Room, + type RoomMember, + RoomStateEvent, + type User, + UserEvent, +} from "matrix-js-sdk/src/matrix"; import { Tooltip } from "@vector-im/compound-web"; import { isPresenceEnabled } from "../../../utils/presence"; @@ -65,12 +72,12 @@ export const useDmMember = (room?: Room): RoomMember | null => { return dmMember; }; -function getPresence(member: RoomMember | null): Presence | null { - if (!member?.user) return null; +function getPresenceFromUser(user: User | null | undefined): Presence | null { + if (!user) return null; - const presence = member.user.presence; - const isOnline = member.user.currentlyActive || presence === "online"; - if (BUSY_PRESENCE_NAME.matches(member.user.presence)) { + const presence = user.presence; + const isOnline = user.currentlyActive || presence === "online"; + if (BUSY_PRESENCE_NAME.matches(presence)) { return Presence.Busy; } if (isOnline) { @@ -86,15 +93,25 @@ function getPresence(member: RoomMember | null): Presence | null { return null; } +function getPresence(room: Room, member: RoomMember | null): Presence | null { + // Fall back to client.getUser() when member.user is not yet linked during initial sync + const user = member?.user ?? (member ? room.client.getUser(member.userId) : null); + return getPresenceFromUser(user); +} + export const usePresence = (room: Room, member: RoomMember | null): Presence | null => { - const [presence, setPresence] = useState(getPresence(member)); + const [presence, setPresence] = useState(getPresence(room, member)); const updatePresence = (): void => { - setPresence(getPresence(member)); + setPresence(getPresence(room, member)); }; useEventEmitter(member?.user, UserEvent.Presence, updatePresence); useEventEmitter(member?.user, UserEvent.CurrentlyActive, updatePresence); - useEffect(updatePresence, [member]); + // Also listen at client level to catch presence events when member.user is not yet linked + useEventEmitter(room.client, UserEvent.Presence, (_event: unknown, user: User) => { + if (user?.userId === member?.userId) updatePresence(); + }); + useEffect(updatePresence, [room, member]); if (getJoinedNonFunctionalMembers(room).length !== 2 || !isPresenceEnabled(room.client)) return null; return presence; 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 61cec3d4fa8..722127cbfd9 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 @@ -99,4 +99,51 @@ describe("WithPresenceIndicator", () => { expect(asFragment()).toMatchSnapshot(); }); + + 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); + + const DM_USER_ID = "@bob:foo.bar"; + const dmRoomMap = { + getUserIdForRoomId: () => { + return DM_USER_ID; + }, + } as unknown as DMRoomMap; + + jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + + // 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); + }); + + // 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)); + + const { container } = renderComponent(); + + const presence = container.querySelector(".mx_WithPresenceIndicator_icon")!; + expect(presence).toBeVisible(); + await userEvent.hover(presence!); + + const tooltip = await waitFor(() => { + const tooltip = document.getElementById(presence.getAttribute("aria-labelledby")!); + expect(tooltip).toBeVisible(); + return tooltip; + }); + + // component should fall back to reading client.getUser() which does have the presence data + // so it should render correctly + expect(tooltip).toHaveTextContent(renderedStr); + }, + ); });