Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions apps/web/src/components/views/avatars/WithPresenceIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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<Presence | null>(getPresence(member));
const [presence, setPresence] = useState<Presence | null>(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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
);
});
Loading