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 35569f35bb254462dd86c16438dab38188db6fbc.

* 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 { type JitsiCallMemberContent, JitsiCallMemberEventType } from "../call-types";
import SdkConfig from "../SdkConfig.ts";
import RoomListStore from "../stores/room-list/RoomListStore.ts";
import { DefaultTagID } from "../stores/room-list/models.ts";
import DMRoomMap from "../utils/DMRoomMap.ts";
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.
* (somewhat cheekily named)
@@ -586,10 +592,24 @@ export class ElementCall extends Call {
const room = client.getRoom(roomId);
if (room !== null && !isVideoRoom(room)) {
params.append(
"sendNotificationType",
RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.DM) ? "ring" : "notification",
);
const isDM = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId);
const oldestCallMember = client.matrixRTC.getRoomSession(room).getOldestMembership();
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");

View File

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

View File

@@ -44,6 +44,7 @@ import { CallStore } from "../../../src/stores/CallStore";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../src/MediaDeviceHandler";
import { storeRoomAliasInCache } from "../../../src/RoomAliasCache.ts";
import { type Call } from "../../../src/models/Call.ts";
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 () => {
const call = { presented: false } as Call;
const getCallSpy = jest.spyOn(CallStore.instance, "getCall").mockReturnValue(call);
await setupAsyncStoreWithClient(CallStore.instance, MatrixClientPeg.safeGet());
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 () => {