Skip to content
Draft
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
2 changes: 1 addition & 1 deletion apps/web/src/MatrixClientPeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
opts.threadSupport = true;
if (SettingsStore.getValue("feature_user_status")) {
opts.unstableMSC4429SyncUserProfileFields = ["org.matrix.msc4426.status"];
opts.unstableMSC4429SyncUserProfileFields = ["org.matrix.msc4426.status", "org.matrix.msc4426.call"];

Check warning on line 269 in apps/web/src/MatrixClientPeg.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 269 is not covered by tests
}

if (SettingsStore.getValue("feature_sliding_sync")) {
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/hooks/useUserStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
import { useTypedEventEmitter } from "./useEventEmitter";
import { useFeatureEnabled } from "./useSettings";
import { validateUserStatus } from "../utils/userStatus";
import { resolveUserStatus } from "../utils/userStatus";

const logger = rootLogger.getChild("useUserStatus");

Expand All @@ -28,13 +28,15 @@
const isEnabled = useFeatureEnabled("feature_user_status");
const matrixClient = useMatrixClientContext();
const [rawUserStatus, setRawUserStatus] = useState<unknown>();
const [rawCallStatus, setRawCallStatus] = useState<unknown>();

useTypedEventEmitter(matrixClient, ClientEvent.UserProfileUpdate, (syncedUserId, syncProfile) => {
if (syncedUserId !== userId) {
return;
}

setRawUserStatus(syncProfile["org.matrix.msc4426.status"]);
setRawCallStatus(syncProfile["org.matrix.msc4426.call"]);
});
useEffect(() => {
(async () => {
Expand All @@ -43,10 +45,12 @@
}
if (!userId) {
setRawUserStatus(undefined);
setRawCallStatus(undefined);

Check warning on line 48 in apps/web/src/hooks/useUserStatus.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 48 is not covered by tests
return;
}
if ((await matrixClient.doesServerSupportExtendedProfiles()) === false) {
setRawUserStatus(undefined);
setRawCallStatus(undefined);
return;
}
try {
Expand All @@ -59,11 +63,21 @@
logger.warn(`Failed to get userStatus for ${userId}`, ex);
}
}
try {
const result = await matrixClient.getExtendedProfileProperty(userId, "org.matrix.msc4426.call");
setRawCallStatus(result);
} catch (ex) {
if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") {
setRawCallStatus(undefined);

Check warning on line 71 in apps/web/src/hooks/useUserStatus.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 71 is not covered by tests
} else {
logger.warn(`Failed to get call status for ${userId}`, ex);
}
}
})();
}, [isEnabled, userId, matrixClient]);
if (!isEnabled) {
return;
}

return validateUserStatus(rawUserStatus);
return resolveUserStatus(rawUserStatus, rawCallStatus);
}
3 changes: 3 additions & 0 deletions apps/web/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -3823,6 +3823,9 @@
"verify_button": "Verify User",
"verify_explainer": "For extra security, verify this user by checking a one-time code on both of your devices."
},
"user_status": {
"on_a_call": "On a call"
},
"voip": {
"already_in_call": "Already in call",
"already_in_call_person": "You're already in a call with this person.",
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/stores/CallStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import WidgetStore from "./WidgetStore";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
import { Call, CallEvent, ConnectionState } from "../models/Call";
import { clearUserOnCall, setUserOnCall } from "../utils/userStatus";

export enum CallStoreEvent {
// Signals a change in the call associated with a given room
Expand Down Expand Up @@ -132,9 +133,21 @@ export class CallStore extends AsyncStoreWithClient<EmptyObject> {
return this._connectedCalls;
}
private set connectedCalls(value: Set<Call>) {
const wasInCall = this._connectedCalls.size > 0;
this._connectedCalls = value;
const nowInCall = value.size > 0;
this.emit(CallStoreEvent.ConnectedCalls, value);

// While the user is participating in a call, advertise an `m.call` profile field (MSC4426)
// so their "on a call" status is visible to others, clearing it once they leave all calls.
// Only act on the empty<->non-empty edge so concurrent calls don't re-write the field.
if (wasInCall !== nowInCall && SettingsStore.getValue("feature_user_status") && this.matrixClient) {
const client = this.matrixClient;
void (nowInCall ? setUserOnCall(client) : clearUserOnCall(client)).catch((err) =>
logger.warn("Failed to update m.call profile field", err),
);
}

// The room IDs are persisted to settings so we can detect unclean disconnects
SettingsStore.setValue(
"activeCallRoomIds",
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/utils/userStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Please see LICENSE files in the repository root for full details.
import { type UserStatus } from "@element-hq/web-shared-components";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";

import { _t } from "../languageHandler";

// MSC4426 defines the maximum length of a status to be 256 bytes of UTF-8,
// so we truncate anything longer than that.
const MAX_STATUS_TEXT_BYTES = 256;
Expand Down Expand Up @@ -35,6 +37,26 @@ export function validateUserStatus(rawUserStatus: unknown): UserStatus | undefin
};
}

export function isUserOnCall(rawCallStatus: unknown): boolean {
return typeof rawCallStatus === "object" && rawCallStatus !== null;
}

/**
* Resolves the effective user status to display, given the raw `m.status` and `m.call` profile
* field values. `m.status` always takes precedence; `m.call` is only shown as a fallback ("On a
* call") when there is no valid `m.status` set.
*/
export function resolveUserStatus(rawUserStatus: unknown, rawCallStatus: unknown): UserStatus | undefined {
const userStatus = validateUserStatus(rawUserStatus);
if (userStatus) {
return userStatus;
}
if (isUserOnCall(rawCallStatus)) {
return { emoji: "🎧", text: _t("user_status|on_a_call") };
}
return undefined;
}

export function setUserStatus(client: MatrixClient, userStatus: UserStatus): Promise<void> {
return client.setExtendedProfileProperty("org.matrix.msc4426.status", {
emoji: userStatus.emoji,
Expand All @@ -45,3 +67,13 @@ export function setUserStatus(client: MatrixClient, userStatus: UserStatus): Pro
export function clearUserStatus(client: MatrixClient): Promise<void> {
return client.setExtendedProfileProperty("org.matrix.msc4426.status", null);
}

export function setUserOnCall(client: MatrixClient): Promise<void> {
return client.setExtendedProfileProperty("org.matrix.msc4426.call", {
call_joined_ts: Date.now(),
});
}

export function clearUserOnCall(client: MatrixClient): Promise<void> {
return client.setExtendedProfileProperty("org.matrix.msc4426.call", null);
}
51 changes: 51 additions & 0 deletions apps/web/test/unit-tests/hooks/useUserStatus-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,55 @@ describe("useUserStatus", () => {
// Should still have original status
expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" });
});

it("returns 'On a call' when m.call is set and there is no m.status", async () => {
client.getExtendedProfileProperty.mockImplementation((_uid, key) => {
if (key === "org.matrix.msc4426.call") return Promise.resolve({});
return Promise.resolve(undefined);
});
const { result } = render();
await waitFor(() => expect(result.current).toEqual({ emoji: "🎧", text: "On a call" }));
});

it("prefers m.status over m.call when both are set", async () => {
client.getExtendedProfileProperty.mockImplementation((_uid, key) => {
if (key === "org.matrix.msc4426.status") return Promise.resolve({ emoji: "🐎", text: "on a horse" });
if (key === "org.matrix.msc4426.call") return Promise.resolve({});
return Promise.resolve(undefined);
});
const { result } = render();
await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" }));
});

it("reverts to m.status when a call ends", async () => {
client.getExtendedProfileProperty.mockImplementation((_uid, key) => {
if (key === "org.matrix.msc4426.status") return Promise.resolve({ emoji: "🐎", text: "on a horse" });
if (key === "org.matrix.msc4426.call") return Promise.resolve({});
return Promise.resolve(undefined);
});
const { result } = render();
await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" }));

client.emit(ClientEvent.UserProfileUpdate, userId, {
"org.matrix.msc4426.status": { emoji: "🐎", text: "on a horse" },
"org.matrix.msc4426.call": undefined,
});

await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" }));
});

it("returns undefined when a call ends and there is no m.status", async () => {
client.getExtendedProfileProperty.mockImplementation((_uid, key) => {
if (key === "org.matrix.msc4426.call") return Promise.resolve({});
return Promise.resolve(undefined);
});
const { result } = render();
await waitFor(() => expect(result.current).toEqual({ emoji: "🎧", text: "On a call" }));

client.emit(ClientEvent.UserProfileUpdate, userId, {
"org.matrix.msc4426.call": undefined,
});

await waitFor(() => expect(result.current).toBeUndefined());
});
});
101 changes: 98 additions & 3 deletions apps/web/test/unit-tests/stores/CallStore-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,28 @@
*/

import { type CallMembership, MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { type MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { type MockedObject } from "jest-mock";
import { logger } from "matrix-js-sdk/src/logger";

import { ElementCall } from "../../../src/models/Call";
import { ConnectionState, ElementCall } from "../../../src/models/Call";
import { CallStore } from "../../../src/stores/CallStore";
import {
setUpClientRoomAndStores,
cleanUpClientRoomAndStores,
setupAsyncStoreWithClient,
enableCalls,
useMockedCalls,
MockedCall,
flushPromises,
} from "../../test-utils";

describe("CallStore", () => {
let client: MockedObject<MatrixClient>;
let room: Room;
let enabledSettings: Set<string>;
beforeEach(() => {
enableCalls();
({ enabledSettings } = enableCalls());
const res = setUpClientRoomAndStores();
client = res.client;
room = res.room;
Expand Down Expand Up @@ -66,4 +71,94 @@ describe("CallStore", () => {
{ type: "type-d", other_data: "baz" },
]);
});

describe("m.call profile field (on-a-call status)", () => {
let call: MockedCall;
let call2: MockedCall;

beforeEach(async () => {
useMockedCalls();
// Reset the singleton so calls from previous tests don't linger in the map
// (setupAsyncStoreWithClient only runs onReady, never onNotReady).
// @ts-ignore protected access
await CallStore.instance.onNotReady();
await setupAsyncStoreWithClient(CallStore.instance, client);
MockedCall.create(room, "1");
call = CallStore.instance.getCall(room.roomId) as MockedCall;
});

const createSecondCall = (): void => {
const room2 = new Room("!2:example.org", client, "@alice:example.org");
client.getRoom.mockImplementation((roomId) => {
if (roomId === room.roomId) return room;
if (roomId === room2.roomId) return room2;
return null;
});
MockedCall.create(room2, "2");
call2 = CallStore.instance.getCall(room2.roomId) as MockedCall;
};

describe("when feature_user_status is enabled", () => {
beforeEach(() => {
enabledSettings.add("feature_user_status");
});

it("sets m.call when the user joins their first call", () => {
call.setConnectionState(ConnectionState.Connected);

expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.call", {
call_joined_ts: expect.any(Number),
});
});

it("clears m.call when the user leaves their last call", () => {
call.setConnectionState(ConnectionState.Connected);
client.setExtendedProfileProperty.mockClear();

call.setConnectionState(ConnectionState.Disconnected);

expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.call", null);
});

it("does not re-write m.call when joining a second concurrent call", () => {
call.setConnectionState(ConnectionState.Connected);
client.setExtendedProfileProperty.mockClear();

createSecondCall();
call2.setConnectionState(ConnectionState.Connected);

expect(client.setExtendedProfileProperty).not.toHaveBeenCalled();
});

it("does not clear m.call while still connected to another call", () => {
call.setConnectionState(ConnectionState.Connected);
createSecondCall();
call2.setConnectionState(ConnectionState.Connected);
client.setExtendedProfileProperty.mockClear();

call.setConnectionState(ConnectionState.Disconnected);

expect(client.setExtendedProfileProperty).not.toHaveBeenCalled();
});

it("swallows failures to update the profile field", async () => {
const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => {});
client.setExtendedProfileProperty.mockRejectedValue(new Error("Server does not support extended profiles"));

expect(() => call.setConnectionState(ConnectionState.Connected)).not.toThrow();
await flushPromises();

expect(warnSpy).toHaveBeenCalledWith("Failed to update m.call profile field", expect.any(Error));
});
});

describe("when feature_user_status is disabled", () => {
it("never touches the m.call profile field", () => {
call.setConnectionState(ConnectionState.Connected);
call.setConnectionState(ConnectionState.Disconnected);

expect(client.setExtendedProfileProperty).not.toHaveBeenCalled();
});
});
});
});
Loading
Loading