New group call experience: Starting and ending calls (#9318)

* Create m.room calls in video rooms, and m.prompt calls otherwise

* Terminate a call when the last person leaves

* Hook up the room header button to a unified CallView component

* Write more tests
This commit is contained in:
Robin 2022-09-27 07:54:51 -04:00 committed by GitHub
parent 54b79c7667
commit ace6591f43
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 695 additions and 512 deletions

View file

@ -79,7 +79,7 @@ export enum CallEvent {
interface CallEventHandlerMap {
[CallEvent.ConnectionState]: (state: ConnectionState, prevState: ConnectionState) => void;
[CallEvent.Participants]: (participants: Set<RoomMember>) => void;
[CallEvent.Participants]: (participants: Set<RoomMember>, prevParticipants: Set<RoomMember>) => void;
[CallEvent.Destroy]: () => void;
}
@ -129,8 +129,9 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
return this._participants;
}
protected set participants(value: Set<RoomMember>) {
const prevValue = this._participants;
this._participants = value;
this.emit(CallEvent.Participants, value);
this.emit(CallEvent.Participants, value, prevValue);
}
constructor(
@ -601,6 +602,7 @@ export class ElementCall extends Call {
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
private participantsExpirationTimer: number | null = null;
private terminationTimer: number | null = null;
private constructor(public readonly groupCall: MatrixEvent, client: MatrixClient) {
// Splice together the Element Call URL for this call
@ -631,6 +633,7 @@ export class ElementCall extends Call {
this.room.on(RoomStateEvent.Update, this.onRoomState);
this.on(CallEvent.ConnectionState, this.onConnectionState);
this.on(CallEvent.Participants, this.onParticipants);
this.updateParticipants();
}
@ -665,8 +668,12 @@ export class ElementCall extends Call {
}
public static async create(room: Room): Promise<void> {
const isVideoRoom = SettingsStore.getValue("feature_video_rooms")
&& SettingsStore.getValue("feature_element_call_video_rooms")
&& room.isCallRoom();
await room.client.sendStateEvent(room.roomId, ElementCall.CALL_EVENT_TYPE.name, {
"m.intent": "m.room",
"m.intent": isVideoRoom ? "m.room" : "m.prompt",
"m.type": "m.video",
}, randomString(24));
}
@ -791,17 +798,45 @@ export class ElementCall extends Call {
WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.getRoomId()!);
this.room.off(RoomStateEvent.Update, this.onRoomState);
this.off(CallEvent.ConnectionState, this.onConnectionState);
this.off(CallEvent.Participants, this.onParticipants);
if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null;
}
if (this.terminationTimer !== null) {
clearTimeout(this.terminationTimer);
this.terminationTimer = null;
}
super.destroy();
}
private onRoomState = () => this.updateParticipants();
private get mayTerminate(): boolean {
return this.groupCall.getContent()["m.intent"] !== "m.room"
&& this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client);
}
private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => {
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 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 onConnectionState = (state: ConnectionState, prevState: ConnectionState) => {
if (
(state === ConnectionState.Connected && !isConnected(prevState))
|| (state === ConnectionState.Disconnected && isConnected(prevState))
@ -810,6 +845,25 @@ export class ElementCall extends Call {
}
};
private onParticipants = async (participants: Set<RoomMember>, prevParticipants: Set<RoomMember>) => {
// 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 we were that last participant, do the termination ourselves
await this.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
// other reasons, we can't rely on other clients to terminate the call.
// Since it's likely that other clients are using this same logic, we wait
// 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);
}
}
};
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
await this.messaging!.transport.reply(ev.detail, {}); // ack