Set Element Call "intents" when starting and answering DM calls. (#30730)

* Start to implement intents for DM calls.

* Refactor and fix intent bugs

* Do not default skipLobby in Element Web

* Remove hacks

* cleanup

* Don't template skipLobby or returnToLobby but inject as required

* Revert "Don't template skipLobby or returnToLobby but inject as required"

This reverts commit 35569f35bb.

* lint

* Fix test

* lint

* Use other intents

* Ensure we test all intents

* lint

* cleanup

* Fix room check

* Update imports

* update test

* Fix RoomViewStore test
This commit is contained in:
Will Hunt
2025-09-12 14:00:48 +01:00
committed by GitHub
parent 33d3df24f9
commit 1e0cdf7b14
3 changed files with 105 additions and 36 deletions

View File

@@ -43,8 +43,7 @@ import { isVideoRoom } from "../utils/video-rooms";
import { FontWatcher } from "../settings/watchers/FontWatcher"; import { FontWatcher } from "../settings/watchers/FontWatcher";
import { type JitsiCallMemberContent, JitsiCallMemberEventType } from "../call-types"; import { type JitsiCallMemberContent, JitsiCallMemberEventType } from "../call-types";
import SdkConfig from "../SdkConfig.ts"; import SdkConfig from "../SdkConfig.ts";
import RoomListStore from "../stores/room-list/RoomListStore.ts"; import DMRoomMap from "../utils/DMRoomMap.ts";
import { DefaultTagID } from "../stores/room-list/models.ts";
const TIMEOUT_MS = 16000; const TIMEOUT_MS = 16000;
@@ -542,6 +541,13 @@ export class JitsiCall extends Call {
}; };
} }
export enum ElementCallIntent {
StartCall = "start_call",
JoinExisting = "join_existing",
StartCallDM = "start_call_dm",
JoinExistingDM = "join_existing_dm",
}
/** /**
* A group call using MSC3401 and Element Call as a backend. * A group call using MSC3401 and Element Call as a backend.
* (somewhat cheekily named) * (somewhat cheekily named)
@@ -586,10 +592,24 @@ export class ElementCall extends Call {
const room = client.getRoom(roomId); const room = client.getRoom(roomId);
if (room !== null && !isVideoRoom(room)) { if (room !== null && !isVideoRoom(room)) {
params.append( const isDM = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId);
"sendNotificationType", const oldestCallMember = client.matrixRTC.getRoomSession(room).getOldestMembership();
RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.DM) ? "ring" : "notification", const hasCallStarted = !!oldestCallMember && oldestCallMember.sender !== client.getSafeUserId();
); if (isDM) {
params.append("sendNotificationType", "ring");
if (hasCallStarted) {
params.append("intent", ElementCallIntent.JoinExistingDM);
} else {
params.append("intent", ElementCallIntent.StartCallDM);
}
} else {
params.append("sendNotificationType", "notification");
if (hasCallStarted) {
params.append("intent", ElementCallIntent.JoinExisting);
} else {
params.append("intent", ElementCallIntent.StartCall);
}
}
} }
const rageshakeSubmitUrl = SdkConfig.get("bug_report_endpoint_url"); const rageshakeSubmitUrl = SdkConfig.get("bug_report_endpoint_url");

View File

@@ -39,8 +39,16 @@ import {
ConnectionState, ConnectionState,
JitsiCall, JitsiCall,
ElementCall, ElementCall,
ElementCallIntent,
} from "../../../src/models/Call"; } from "../../../src/models/Call";
import { stubClient, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../../test-utils"; import {
stubClient,
mkEvent,
mkRoomMember,
setupAsyncStoreWithClient,
mockPlatformPeg,
MockEventEmitter,
} from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import WidgetStore from "../../../src/stores/WidgetStore"; import WidgetStore from "../../../src/stores/WidgetStore";
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore"; import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore";
@@ -50,8 +58,6 @@ import SettingsStore from "../../../src/settings/SettingsStore";
import { Anonymity, PosthogAnalytics } from "../../../src/PosthogAnalytics"; import { Anonymity, PosthogAnalytics } from "../../../src/PosthogAnalytics";
import { type SettingKey } from "../../../src/settings/Settings.tsx"; import { type SettingKey } from "../../../src/settings/Settings.tsx";
import SdkConfig from "../../../src/SdkConfig.ts"; import SdkConfig from "../../../src/SdkConfig.ts";
import RoomListStore from "../../../src/stores/room-list/RoomListStore.ts";
import { DefaultTagID } from "../../../src/stores/room-list/models.ts";
import DMRoomMap from "../../../src/utils/DMRoomMap.ts"; import DMRoomMap from "../../../src/utils/DMRoomMap.ts";
const enabledSettings = new Set(["feature_group_calls", "feature_video_rooms", "feature_element_call_video_rooms"]); const enabledSettings = new Set(["feature_group_calls", "feature_video_rooms", "feature_element_call_video_rooms"]);
@@ -65,6 +71,7 @@ const setUpClientRoomAndStores = (): {
alice: RoomMember; alice: RoomMember;
bob: RoomMember; bob: RoomMember;
carol: RoomMember; carol: RoomMember;
roomSession: Mocked<MatrixRTCSession>;
} => { } => {
stubClient(); stubClient();
const client = mocked<MatrixClient>(MatrixClientPeg.safeGet()); const client = mocked<MatrixClient>(MatrixClientPeg.safeGet());
@@ -93,12 +100,13 @@ const setUpClientRoomAndStores = (): {
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join); jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null)); client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
client.matrixRTC.getRoomSession.mockImplementation((roomId) => { const roomSession = new MockEventEmitter({
const session = new EventEmitter() as MatrixRTCSession; memberships: [],
session.memberships = []; getOldestMembership: jest.fn().mockReturnValue(undefined),
return session; }) as Mocked<MatrixRTCSession>;
});
client.matrixRTC.getRoomSession.mockReturnValue(roomSession);
client.getRooms.mockReturnValue([room]); client.getRooms.mockReturnValue([room]);
client.getUserId.mockReturnValue(alice.userId); client.getUserId.mockReturnValue(alice.userId);
client.getDeviceId.mockReturnValue("alices_device"); client.getDeviceId.mockReturnValue("alices_device");
@@ -120,7 +128,7 @@ const setUpClientRoomAndStores = (): {
setupAsyncStoreWithClient(WidgetStore.instance, client); setupAsyncStoreWithClient(WidgetStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
return { client, room, alice, bob, carol }; return { client, room, alice, bob, carol, roomSession };
}; };
const cleanUpClientRoomAndStores = (client: MatrixClient, room: Room) => { const cleanUpClientRoomAndStores = (client: MatrixClient, room: Room) => {
@@ -553,14 +561,14 @@ describe("ElementCall", () => {
let client: Mocked<MatrixClient>; let client: Mocked<MatrixClient>;
let room: Room; let room: Room;
let alice: RoomMember; let alice: RoomMember;
let roomSession: Mocked<MatrixRTCSession>;
function setRoomMembers(memberIds: string[]) { function setRoomMembers(memberIds: string[]) {
jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember)); jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember));
} }
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
({ client, room, alice } = setUpClientRoomAndStores()); ({ client, room, alice, roomSession } = setUpClientRoomAndStores());
SdkConfig.reset(); SdkConfig.reset();
}); });
@@ -571,7 +579,16 @@ describe("ElementCall", () => {
}); });
describe("get", () => { describe("get", () => {
afterEach(() => Call.get(room)?.destroy()); let getUserIdForRoomIdSpy: jest.SpyInstance;
beforeEach(() => {
getUserIdForRoomIdSpy = jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId");
});
afterEach(() => {
Call.get(room)?.destroy();
getUserIdForRoomIdSpy.mockRestore();
});
it("finds no calls", () => { it("finds no calls", () => {
expect(Call.get(room)).toBeNull(); expect(Call.get(room)).toBeNull();
@@ -600,11 +617,7 @@ describe("ElementCall", () => {
it("finds ongoing calls that are created by the session manager", async () => { it("finds ongoing calls that are created by the session manager", async () => {
// There is an existing session created by another user in this room. // There is an existing session created by another user in this room.
client.matrixRTC.getRoomSession.mockReturnValue({ roomSession.memberships.push({} as CallMembership);
on: (ev: any, fn: any) => {},
off: (ev: any, fn: any) => {},
memberships: [{ fakeVal: "fake membership" }],
} as unknown as MatrixRTCSession);
const call = Call.get(room); const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
}); });
@@ -750,19 +763,50 @@ describe("ElementCall", () => {
expect(urlParams.get("analyticsID")).toBeFalsy(); expect(urlParams.get("analyticsID")).toBeFalsy();
}); });
it("requests ringing notifications in DMs", async () => { it("requests ringing notifications and correct intent in DMs", async () => {
const tagsSpy = jest.spyOn(RoomListStore.instance, "getTagsForRoom"); getUserIdForRoomIdSpy.mockImplementation((roomId: string) =>
try { room.roomId === roomId ? "any-user" : undefined,
tagsSpy.mockReturnValue([DefaultTagID.DM]); );
ElementCall.create(room); ElementCall.create(room);
const call = Call.get(room); const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
expect(urlParams.get("sendNotificationType")).toBe("ring"); expect(urlParams.get("sendNotificationType")).toBe("ring");
} finally { expect(urlParams.get("intent")).toBe(ElementCallIntent.StartCallDM);
tagsSpy.mockRestore(); });
}
it("requests correct intent when answering DMs", async () => {
roomSession.getOldestMembership.mockReturnValue({} as CallMembership);
getUserIdForRoomIdSpy.mockImplementation((roomId: string) =>
room.roomId === roomId ? "any-user" : undefined,
);
ElementCall.create(room);
const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
expect(urlParams.get("intent")).toBe(ElementCallIntent.JoinExistingDM);
});
it("requests correct intent when creating a non-DM call", async () => {
roomSession.getOldestMembership.mockReturnValue(undefined);
ElementCall.create(room);
const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
expect(urlParams.get("intent")).toBe(ElementCallIntent.StartCall);
});
it("requests correct intent when joining a non-DM call", async () => {
roomSession.getOldestMembership.mockReturnValue({} as CallMembership);
ElementCall.create(room);
const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1));
expect(urlParams.get("intent")).toBe(ElementCallIntent.JoinExisting);
}); });
it("requests visual notifications in non-DMs", async () => { it("requests visual notifications in non-DMs", async () => {

View File

@@ -44,6 +44,7 @@ import { CallStore } from "../../../src/stores/CallStore";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../src/MediaDeviceHandler"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../src/MediaDeviceHandler";
import { storeRoomAliasInCache } from "../../../src/RoomAliasCache.ts"; import { storeRoomAliasInCache } from "../../../src/RoomAliasCache.ts";
import { type Call } from "../../../src/models/Call.ts";
jest.mock("../../../src/Modal"); jest.mock("../../../src/Modal");
@@ -361,8 +362,12 @@ describe("RoomViewStore", function () {
}); });
it("when viewing a call without a broadcast, it should not raise an error", async () => { it("when viewing a call without a broadcast, it should not raise an error", async () => {
const call = { presented: false } as Call;
const getCallSpy = jest.spyOn(CallStore.instance, "getCall").mockReturnValue(call);
await setupAsyncStoreWithClient(CallStore.instance, MatrixClientPeg.safeGet()); await setupAsyncStoreWithClient(CallStore.instance, MatrixClientPeg.safeGet());
await viewCall(); await viewCall();
expect(getCallSpy).toHaveBeenCalledWith(roomId);
expect(call.presented).toEqual(true);
}); });
it("should display an error message when the room is unreachable via the roomId", async () => { it("should display an error message when the room is unreachable via the roomId", async () => {