Use native js-sdk group call support (#9625)
* Use native js-sdk group call support Now that the js-sdk supports group calls natively, our group call implementation can be simplified a bit. Switching to the js-sdk implementation also brings the react-sdk up to date with recent MSC3401 changes, and adds support for joining calls from multiple devices. (So, the previous logic which sent to-device messages to prevent multi-device sessions is no longer necessary.) * Fix strings * Fix strict type errors
This commit is contained in:
parent
3c7781a561
commit
2c612d5aa1
20 changed files with 383 additions and 567 deletions
|
@ -17,13 +17,20 @@ limitations under the License.
|
|||
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { RoomEvent } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { NamespacedValue } from "matrix-js-sdk/src/NamespacedValue";
|
||||
import { IWidgetApiRequest, MatrixWidgetType } from "matrix-widget-api";
|
||||
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
|
||||
import {
|
||||
GroupCall,
|
||||
GroupCallEvent,
|
||||
GroupCallIntent,
|
||||
GroupCallState,
|
||||
GroupCallType,
|
||||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import type EventEmitter from "events";
|
||||
import type { IMyDevice } from "matrix-js-sdk/src/client";
|
||||
|
@ -89,7 +96,10 @@ export enum CallEvent {
|
|||
|
||||
interface CallEventHandlerMap {
|
||||
[CallEvent.ConnectionState]: (state: ConnectionState, prevState: ConnectionState) => void;
|
||||
[CallEvent.Participants]: (participants: Set<RoomMember>, prevParticipants: Set<RoomMember>) => void;
|
||||
[CallEvent.Participants]: (
|
||||
participants: Map<RoomMember, Set<string>>,
|
||||
prevParticipants: Map<RoomMember, Set<string>>,
|
||||
) => void;
|
||||
[CallEvent.Layout]: (layout: Layout) => void;
|
||||
[CallEvent.Destroy]: () => void;
|
||||
}
|
||||
|
@ -135,11 +145,14 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
|||
return isConnected(this.connectionState);
|
||||
}
|
||||
|
||||
private _participants = new Set<RoomMember>();
|
||||
public get participants(): Set<RoomMember> {
|
||||
private _participants = new Map<RoomMember, Set<string>>();
|
||||
/**
|
||||
* The participants in the call, as a map from members to device IDs.
|
||||
*/
|
||||
public get participants(): Map<RoomMember, Set<string>> {
|
||||
return this._participants;
|
||||
}
|
||||
protected set participants(value: Set<RoomMember>) {
|
||||
protected set participants(value: Map<RoomMember, Set<string>>) {
|
||||
const prevValue = this._participants;
|
||||
this._participants = value;
|
||||
this.emit(CallEvent.Participants, value, prevValue);
|
||||
|
@ -164,68 +177,11 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
|||
return ElementCall.get(room) ?? JitsiCall.get(room);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the connected devices associated with the given user in room state.
|
||||
* @param userId The user's ID.
|
||||
* @returns The IDs of the user's connected devices.
|
||||
*/
|
||||
protected abstract getDevices(userId: string): string[];
|
||||
|
||||
/**
|
||||
* Sets the connected devices associated with ourselves in room state.
|
||||
* @param devices The devices with which we're connected.
|
||||
*/
|
||||
protected abstract setDevices(devices: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Updates our member state with the devices returned by the given function.
|
||||
* @param fn A function from the current devices to the new devices. If it
|
||||
* returns null, the update is skipped.
|
||||
*/
|
||||
protected async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise<void> {
|
||||
if (this.room.getMyMembership() !== "join") return;
|
||||
|
||||
const devices = fn(this.getDevices(this.client.getUserId()!));
|
||||
if (devices) {
|
||||
await this.setDevices(devices);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a routine check of the call's associated room state, cleaning up
|
||||
* any data left over from an unclean disconnection.
|
||||
*/
|
||||
public async clean(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const { devices: myDevices } = await this.client.getDevices();
|
||||
const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
|
||||
|
||||
// Clean up our member state by filtering out logged out devices,
|
||||
// inactive devices, and our own device (if we're disconnected)
|
||||
await this.updateDevices(devices => {
|
||||
const newDevices = devices.filter(d => {
|
||||
const device = deviceMap.get(d);
|
||||
return device?.last_seen_ts !== undefined
|
||||
&& !(d === this.client.getDeviceId() && !this.connected)
|
||||
&& (now - device.last_seen_ts) < this.STUCK_DEVICE_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
// Skip the update if the devices are unchanged
|
||||
return newDevices.length === devices.length ? null : newDevices;
|
||||
});
|
||||
}
|
||||
|
||||
protected async addOurDevice(): Promise<void> {
|
||||
await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId())));
|
||||
}
|
||||
|
||||
protected async removeOurDevice(): Promise<void> {
|
||||
await this.updateDevices(devices => {
|
||||
const devicesSet = new Set(devices);
|
||||
devicesSet.delete(this.client.getDeviceId());
|
||||
return Array.from(devicesSet);
|
||||
});
|
||||
}
|
||||
public abstract clean(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Contacts the widget to connect to the call.
|
||||
|
@ -384,7 +340,7 @@ export class JitsiCall extends Call {
|
|||
this.participantsExpirationTimer = null;
|
||||
}
|
||||
|
||||
const members = new Set<RoomMember>();
|
||||
const participants = new Map<RoomMember, Set<string>>();
|
||||
const now = Date.now();
|
||||
let allExpireAt = Infinity;
|
||||
|
||||
|
@ -392,44 +348,95 @@ export class JitsiCall extends Call {
|
|||
const member = this.room.getMember(e.getStateKey()!);
|
||||
const content = e.getContent<JitsiCallMemberContent>();
|
||||
const expiresAt = typeof content.expires_ts === "number" ? content.expires_ts : -Infinity;
|
||||
let devices = expiresAt > now && Array.isArray(content.devices) ? content.devices : [];
|
||||
let devices = expiresAt > now && Array.isArray(content.devices)
|
||||
? content.devices.filter(d => typeof d === "string")
|
||||
: [];
|
||||
|
||||
// Apply local echo for the disconnected case
|
||||
if (!this.connected && member?.userId === this.client.getUserId()) {
|
||||
devices = devices.filter(d => d !== this.client.getDeviceId());
|
||||
}
|
||||
// Must have a connected device and still be joined to the room
|
||||
if (devices.length && member?.membership === "join") {
|
||||
members.add(member);
|
||||
if (devices.length > 0 && member?.membership === "join") {
|
||||
participants.set(member, new Set(devices));
|
||||
if (expiresAt < allExpireAt) allExpireAt = expiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply local echo for the connected case
|
||||
if (this.connected) members.add(this.room.getMember(this.client.getUserId()!)!);
|
||||
if (this.connected) {
|
||||
const localMember = this.room.getMember(this.client.getUserId()!)!;
|
||||
let devices = participants.get(localMember);
|
||||
if (devices === undefined) {
|
||||
devices = new Set();
|
||||
participants.set(localMember, devices);
|
||||
}
|
||||
|
||||
this.participants = members;
|
||||
devices.add(this.client.getDeviceId()!);
|
||||
}
|
||||
|
||||
this.participants = participants;
|
||||
if (allExpireAt < Infinity) {
|
||||
this.participantsExpirationTimer = setTimeout(() => this.updateParticipants(), allExpireAt - now);
|
||||
}
|
||||
}
|
||||
|
||||
protected getDevices(userId: string): string[] {
|
||||
const event = this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, userId);
|
||||
/**
|
||||
* Updates our member state with the devices returned by the given function.
|
||||
* @param fn A function from the current devices to the new devices. If it
|
||||
* returns null, the update is skipped.
|
||||
*/
|
||||
private async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise<void> {
|
||||
if (this.room.getMyMembership() !== "join") return;
|
||||
|
||||
const event = this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, this.client.getUserId()!);
|
||||
const content = event?.getContent<JitsiCallMemberContent>();
|
||||
const expiresAt = typeof content?.expires_ts === "number" ? content.expires_ts : -Infinity;
|
||||
return expiresAt > Date.now() && Array.isArray(content?.devices) ? content.devices : [];
|
||||
const devices = expiresAt > Date.now() && Array.isArray(content?.devices) ? content!.devices : [];
|
||||
const newDevices = fn(devices);
|
||||
|
||||
if (newDevices !== null) {
|
||||
const newContent: JitsiCallMemberContent = {
|
||||
devices: newDevices,
|
||||
expires_ts: Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
await this.client.sendStateEvent(
|
||||
this.roomId, JitsiCall.MEMBER_EVENT_TYPE, newContent, this.client.getUserId()!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected async setDevices(devices: string[]): Promise<void> {
|
||||
const content: JitsiCallMemberContent = {
|
||||
devices,
|
||||
expires_ts: Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
|
||||
};
|
||||
public async clean(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const { devices: myDevices } = await this.client.getDevices();
|
||||
const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
|
||||
|
||||
await this.client.sendStateEvent(
|
||||
this.roomId, JitsiCall.MEMBER_EVENT_TYPE, content, this.client.getUserId()!,
|
||||
);
|
||||
// Clean up our member state by filtering out logged out devices,
|
||||
// inactive devices, and our own device (if we're disconnected)
|
||||
await this.updateDevices(devices => {
|
||||
const newDevices = devices.filter(d => {
|
||||
const device = deviceMap.get(d);
|
||||
return device?.last_seen_ts !== undefined
|
||||
&& !(d === this.client.getDeviceId() && !this.connected)
|
||||
&& (now - device.last_seen_ts) < this.STUCK_DEVICE_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
// Skip the update if the devices are unchanged
|
||||
return newDevices.length === devices.length ? null : newDevices;
|
||||
});
|
||||
}
|
||||
|
||||
private async addOurDevice(): Promise<void> {
|
||||
await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId()!)));
|
||||
}
|
||||
|
||||
private async removeOurDevice(): Promise<void> {
|
||||
await this.updateDevices(devices => {
|
||||
const devicesSet = new Set(devices);
|
||||
devicesSet.delete(this.client.getDeviceId()!);
|
||||
return Array.from(devicesSet);
|
||||
});
|
||||
}
|
||||
|
||||
protected async performConnection(
|
||||
|
@ -591,31 +598,15 @@ export class JitsiCall extends Call {
|
|||
};
|
||||
}
|
||||
|
||||
export interface ElementCallMemberContent {
|
||||
"m.expires_ts": number;
|
||||
"m.calls": {
|
||||
"m.call_id": string;
|
||||
"m.devices": {
|
||||
device_id: string;
|
||||
session_id: string;
|
||||
feeds: unknown[]; // We don't care about what these are
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A group call using MSC3401 and Element Call as a backend.
|
||||
* (somewhat cheekily named)
|
||||
*/
|
||||
export class ElementCall extends Call {
|
||||
public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call");
|
||||
public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call.member");
|
||||
public static readonly DUPLICATE_CALL_DEVICE_EVENT_TYPE = "io.element.duplicate_call_device";
|
||||
public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallPrefix);
|
||||
public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallMemberPrefix);
|
||||
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
|
||||
|
||||
private kickedOutByAnotherDevice = false;
|
||||
private connectionTime: number | null = null;
|
||||
private participantsExpirationTimer: number | null = null;
|
||||
private terminationTimer: number | null = null;
|
||||
|
||||
private _layout = Layout.Tile;
|
||||
|
@ -627,7 +618,7 @@ export class ElementCall extends Call {
|
|||
this.emit(CallEvent.Layout, value);
|
||||
}
|
||||
|
||||
private constructor(public readonly groupCall: MatrixEvent, client: MatrixClient) {
|
||||
private constructor(public readonly groupCall: GroupCall, client: MatrixClient) {
|
||||
// Splice together the Element Call URL for this call
|
||||
const url = new URL(SdkConfig.get("element_call").url ?? DEFAULTS.element_call.url!);
|
||||
url.pathname = "/room";
|
||||
|
@ -636,8 +627,8 @@ export class ElementCall extends Call {
|
|||
preload: "",
|
||||
hideHeader: "",
|
||||
userId: client.getUserId()!,
|
||||
deviceId: client.getDeviceId(),
|
||||
roomId: groupCall.getRoomId()!,
|
||||
deviceId: client.getDeviceId()!,
|
||||
roomId: groupCall.room.roomId,
|
||||
baseUrl: client.baseUrl,
|
||||
lang: getCurrentLanguage().replace("_", "-"),
|
||||
});
|
||||
|
@ -652,14 +643,15 @@ export class ElementCall extends Call {
|
|||
name: "Element Call",
|
||||
type: MatrixWidgetType.Custom,
|
||||
url: url.toString(),
|
||||
}, groupCall.getRoomId()!),
|
||||
}, groupCall.room.roomId),
|
||||
client,
|
||||
);
|
||||
|
||||
this.groupCall.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
|
||||
this.room.on(RoomStateEvent.Update, this.onRoomState);
|
||||
this.on(CallEvent.ConnectionState, this.onConnectionState);
|
||||
this.on(CallEvent.Participants, this.onParticipants);
|
||||
groupCall.on(GroupCallEvent.ParticipantsChanged, this.onGroupCallParticipants);
|
||||
groupCall.on(GroupCallEvent.GroupCallStateChanged, this.onGroupCallState);
|
||||
|
||||
this.updateParticipants();
|
||||
}
|
||||
|
||||
|
@ -672,22 +664,8 @@ export class ElementCall extends Call {
|
|||
&& room.isCallRoom()
|
||||
)
|
||||
) {
|
||||
const groupCalls = ElementCall.CALL_EVENT_TYPE.names.flatMap(eventType =>
|
||||
room.currentState.getStateEvents(eventType),
|
||||
);
|
||||
|
||||
// Find the newest unterminated call
|
||||
let groupCall: MatrixEvent | null = null;
|
||||
for (const event of groupCalls) {
|
||||
if (
|
||||
!("m.terminated" in event.getContent())
|
||||
&& (groupCall === null || event.getTs() > groupCall.getTs())
|
||||
) {
|
||||
groupCall = event;
|
||||
}
|
||||
}
|
||||
|
||||
if (groupCall !== null) return new ElementCall(groupCall, room.client);
|
||||
const groupCall = room.client.groupCallEventHandler!.groupCalls.get(room.roomId);
|
||||
if (groupCall !== undefined) return new ElementCall(groupCall, room.client);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -698,113 +676,25 @@ export class ElementCall extends Call {
|
|||
&& SettingsStore.getValue("feature_element_call_video_rooms")
|
||||
&& room.isCallRoom();
|
||||
|
||||
await room.client.sendStateEvent(room.roomId, ElementCall.CALL_EVENT_TYPE.name, {
|
||||
"m.intent": isVideoRoom ? "m.room" : "m.prompt",
|
||||
"m.type": "m.video",
|
||||
}, randomString(24));
|
||||
}
|
||||
|
||||
private updateParticipants() {
|
||||
if (this.participantsExpirationTimer !== null) {
|
||||
clearTimeout(this.participantsExpirationTimer);
|
||||
this.participantsExpirationTimer = null;
|
||||
}
|
||||
|
||||
const members = new Set<RoomMember>();
|
||||
const now = Date.now();
|
||||
let allExpireAt = Infinity;
|
||||
|
||||
const memberEvents = ElementCall.MEMBER_EVENT_TYPE.names.flatMap(eventType =>
|
||||
this.room.currentState.getStateEvents(eventType),
|
||||
const groupCall = new GroupCall(
|
||||
room.client,
|
||||
room,
|
||||
GroupCallType.Video,
|
||||
false,
|
||||
isVideoRoom ? GroupCallIntent.Room : GroupCallIntent.Prompt,
|
||||
);
|
||||
|
||||
for (const e of memberEvents) {
|
||||
const member = this.room.getMember(e.getStateKey()!);
|
||||
const content = e.getContent<ElementCallMemberContent>();
|
||||
const expiresAt = typeof content["m.expires_ts"] === "number" ? content["m.expires_ts"] : -Infinity;
|
||||
const calls = expiresAt > now && Array.isArray(content["m.calls"]) ? content["m.calls"] : [];
|
||||
const call = calls.find(call => call["m.call_id"] === this.groupCall.getStateKey());
|
||||
let devices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
|
||||
|
||||
// Apply local echo for the disconnected case
|
||||
if (!this.connected && member?.userId === this.client.getUserId()) {
|
||||
devices = devices.filter(d => d.device_id !== this.client.getDeviceId());
|
||||
}
|
||||
// Must have a connected device and still be joined to the room
|
||||
if (devices.length && member?.membership === "join") {
|
||||
members.add(member);
|
||||
if (expiresAt < allExpireAt) allExpireAt = expiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply local echo for the connected case
|
||||
if (this.connected) members.add(this.room.getMember(this.client.getUserId()!)!);
|
||||
|
||||
this.participants = members;
|
||||
if (allExpireAt < Infinity) {
|
||||
this.participantsExpirationTimer = setTimeout(() => this.updateParticipants(), allExpireAt - now);
|
||||
}
|
||||
await groupCall.create();
|
||||
}
|
||||
|
||||
private getCallsState(userId: string): ElementCallMemberContent["m.calls"] {
|
||||
const event = (() => {
|
||||
for (const eventType of ElementCall.MEMBER_EVENT_TYPE.names) {
|
||||
const e = this.room.currentState.getStateEvents(eventType, userId);
|
||||
if (e) return e;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const content = event?.getContent<ElementCallMemberContent>();
|
||||
const expiresAt = typeof content?.["m.expires_ts"] === "number" ? content["m.expires_ts"] : -Infinity;
|
||||
return expiresAt > Date.now() && Array.isArray(content?.["m.calls"]) ? content!["m.calls"] : [];
|
||||
}
|
||||
|
||||
protected getDevices(userId: string): string[] {
|
||||
const calls = this.getCallsState(userId);
|
||||
const call = calls.find(call => call["m.call_id"] === this.groupCall.getStateKey());
|
||||
const devices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
|
||||
return devices.map(d => d.device_id);
|
||||
}
|
||||
|
||||
protected async setDevices(devices: string[]): Promise<void> {
|
||||
const calls = this.getCallsState(this.client.getUserId()!);
|
||||
const call = calls.find(c => c["m.call_id"] === this.groupCall.getStateKey())!;
|
||||
const prevDevices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
|
||||
const prevDevicesMap = new Map(prevDevices.map(d => [d.device_id, d]));
|
||||
|
||||
const newContent: ElementCallMemberContent = {
|
||||
"m.expires_ts": Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
|
||||
"m.calls": [
|
||||
{
|
||||
"m.call_id": this.groupCall.getStateKey()!,
|
||||
// This method will only ever be used to remove devices, so
|
||||
// it's safe to assume that all requested devices are
|
||||
// present in the map
|
||||
"m.devices": devices.map(d => prevDevicesMap.get(d)!),
|
||||
},
|
||||
...calls.filter(c => c !== call),
|
||||
],
|
||||
};
|
||||
|
||||
await this.client.sendStateEvent(
|
||||
this.roomId, ElementCall.MEMBER_EVENT_TYPE.name, newContent, this.client.getUserId()!,
|
||||
);
|
||||
public clean(): Promise<void> {
|
||||
return this.groupCall.cleanMemberState();
|
||||
}
|
||||
|
||||
protected async performConnection(
|
||||
audioInput: MediaDeviceInfo | null,
|
||||
videoInput: MediaDeviceInfo | null,
|
||||
): Promise<void> {
|
||||
this.kickedOutByAnotherDevice = false;
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
|
||||
this.connectionTime = Date.now();
|
||||
await this.client.sendToDevice(ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE, {
|
||||
[this.client.getUserId()]: {
|
||||
"*": { device_id: this.client.getDeviceId(), timestamp: this.connectionTime },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
|
||||
audioInput: audioInput?.label ?? null,
|
||||
|
@ -829,7 +719,6 @@ export class ElementCall extends Call {
|
|||
}
|
||||
|
||||
public setDisconnected() {
|
||||
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
|
||||
this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
|
||||
this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
|
||||
|
@ -838,16 +727,12 @@ export class ElementCall extends Call {
|
|||
}
|
||||
|
||||
public destroy() {
|
||||
this.groupCall.off(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
|
||||
WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.getRoomId()!);
|
||||
this.room.off(RoomStateEvent.Update, this.onRoomState);
|
||||
WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.room.roomId);
|
||||
this.off(CallEvent.ConnectionState, this.onConnectionState);
|
||||
this.off(CallEvent.Participants, this.onParticipants);
|
||||
this.groupCall.off(GroupCallEvent.ParticipantsChanged, this.onGroupCallParticipants);
|
||||
this.groupCall.off(GroupCallEvent.GroupCallStateChanged, this.onGroupCallState);
|
||||
|
||||
if (this.participantsExpirationTimer !== null) {
|
||||
clearTimeout(this.participantsExpirationTimer);
|
||||
this.participantsExpirationTimer = null;
|
||||
}
|
||||
if (this.terminationTimer !== null) {
|
||||
clearTimeout(this.terminationTimer);
|
||||
this.terminationTimer = null;
|
||||
|
@ -868,50 +753,35 @@ export class ElementCall extends Call {
|
|||
await this.messaging!.transport.send(action, {});
|
||||
}
|
||||
|
||||
private updateParticipants() {
|
||||
const participants = new Map<RoomMember, Set<string>>();
|
||||
|
||||
for (const [member, deviceMap] of this.groupCall.participants) {
|
||||
participants.set(member, new Set(deviceMap.keys()));
|
||||
}
|
||||
|
||||
// We never enter group calls natively, so the GroupCall will think it's
|
||||
// disconnected regardless of what our call member state says. Thus we
|
||||
// have to insert our own device manually when connected via the widget.
|
||||
if (this.connected) {
|
||||
const localMember = this.room.getMember(this.client.getUserId()!)!;
|
||||
let devices = participants.get(localMember);
|
||||
if (devices === undefined) {
|
||||
devices = new Set();
|
||||
participants.set(localMember, devices);
|
||||
}
|
||||
|
||||
devices.add(this.client.getDeviceId()!);
|
||||
}
|
||||
|
||||
this.participants = participants;
|
||||
}
|
||||
|
||||
private get mayTerminate(): boolean {
|
||||
if (this.kickedOutByAnotherDevice) return false;
|
||||
if (this.groupCall.getContent()["m.intent"] === "m.room") return false;
|
||||
if (
|
||||
!this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client)
|
||||
) return false;
|
||||
|
||||
return true;
|
||||
return this.groupCall.intent !== GroupCallIntent.Room
|
||||
&& this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client);
|
||||
}
|
||||
|
||||
private async terminate(): Promise<void> {
|
||||
await this.client.sendStateEvent(
|
||||
this.roomId,
|
||||
ElementCall.CALL_EVENT_TYPE.name,
|
||||
{ ...this.groupCall.getContent(), "m.terminated": "Call ended" },
|
||||
this.groupCall.getStateKey(),
|
||||
);
|
||||
}
|
||||
|
||||
private onBeforeRedaction = (): void => {
|
||||
this.disconnect();
|
||||
};
|
||||
|
||||
private onRoomState = () => {
|
||||
this.updateParticipants();
|
||||
|
||||
// Destroy the call if it's been terminated
|
||||
const newGroupCall = this.room.currentState.getStateEvents(
|
||||
this.groupCall.getType(), this.groupCall.getStateKey()!,
|
||||
);
|
||||
if ("m.terminated" in newGroupCall.getContent()) this.destroy();
|
||||
};
|
||||
|
||||
private onToDeviceEvent = (event: MatrixEvent): void => {
|
||||
const content = event.getContent();
|
||||
if (event.getType() !== ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE) return;
|
||||
if (event.getSender() !== this.client.getUserId()) return;
|
||||
if (content.device_id === this.client.getDeviceId()) return;
|
||||
if (content.timestamp <= this.connectionTime) return;
|
||||
|
||||
this.kickedOutByAnotherDevice = true;
|
||||
this.disconnect();
|
||||
};
|
||||
|
||||
private onConnectionState = (state: ConnectionState, prevState: ConnectionState) => {
|
||||
if (
|
||||
(state === ConnectionState.Connected && !isConnected(prevState))
|
||||
|
@ -921,12 +791,21 @@ export class ElementCall extends Call {
|
|||
}
|
||||
};
|
||||
|
||||
private onParticipants = async (participants: Set<RoomMember>, prevParticipants: Set<RoomMember>) => {
|
||||
private onParticipants = async (
|
||||
participants: Map<RoomMember, Set<string>>,
|
||||
prevParticipants: Map<RoomMember, Set<string>>,
|
||||
) => {
|
||||
let participantCount = 0;
|
||||
for (const devices of participants.values()) participantCount += devices.size;
|
||||
|
||||
let prevParticipantCount = 0;
|
||||
for (const devices of prevParticipants.values()) prevParticipantCount += devices.size;
|
||||
|
||||
// If the last participant disconnected, terminate the call
|
||||
if (participants.size === 0 && prevParticipants.size > 0 && this.mayTerminate) {
|
||||
if (prevParticipants.has(this.room.getMember(this.client.getUserId()!)!)) {
|
||||
if (participantCount === 0 && prevParticipantCount > 0 && this.mayTerminate) {
|
||||
if (prevParticipants.get(this.room.getMember(this.client.getUserId()!)!)?.has(this.client.getDeviceId()!)) {
|
||||
// If we were that last participant, do the termination ourselves
|
||||
await this.terminate();
|
||||
await this.groupCall.terminate();
|
||||
} else {
|
||||
// We don't appear to have been the last participant, but because of
|
||||
// the potential for races, users lacking permission, and a myriad of
|
||||
|
@ -935,11 +814,20 @@ export class ElementCall extends Call {
|
|||
// randomly between 2 and 8 seconds before terminating the call, to
|
||||
// probabilistically reduce event spam. If someone else beats us to it,
|
||||
// this timer will be automatically cleared upon the call's destruction.
|
||||
this.terminationTimer = setTimeout(() => this.terminate(), Math.random() * 6000 + 2000);
|
||||
this.terminationTimer = setTimeout(
|
||||
() => this.groupCall.terminate(),
|
||||
Math.random() * 6000 + 2000,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onGroupCallParticipants = () => this.updateParticipants();
|
||||
|
||||
private onGroupCallState = (state: GroupCallState) => {
|
||||
if (state === GroupCallState.Ended) this.destroy();
|
||||
};
|
||||
|
||||
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
|
||||
ev.preventDefault();
|
||||
await this.messaging!.transport.reply(ev.detail, {}); // ack
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue