diff --git a/src/ConferenceCallManager.js b/src/ConferenceCallManager.js index 2a04a510..70ff4d35 100644 --- a/src/ConferenceCallManager.js +++ b/src/ConferenceCallManager.js @@ -16,6 +16,7 @@ limitations under the License. import EventEmitter from "events"; import { ConferenceCallDebugger } from "./ConferenceCallDebugger"; +import { randomString } from "./randomstring"; const CONF_ROOM = "me.robertlong.conf"; const CONF_PARTICIPANT = "me.robertlong.conf.participant"; @@ -186,8 +187,17 @@ export class ConferenceCallManager extends EventEmitter { call.removeListener("hangup", onHangup); call.removeListener("replaced", onReplaced); + const userId = call.opponentMember.userId; - this._addCall(call, userId); + const existingParticipant = this.participants.find( + (p) => p.userId === userId + ); + + if (existingParticipant) { + existingParticipant.call = call; + } + + this._addCall(call); call.answer(); this.emit("call", call); } @@ -270,7 +280,7 @@ export class ConferenceCallManager extends EventEmitter { } const call = this.client.createCall(this.roomId, userId); - this._addCall(call, userId); + this._addCall(call); call.placeVideoCall().then(() => { this.emit("call", call); }); @@ -314,23 +324,21 @@ export class ConferenceCallManager extends EventEmitter { } const userId = call.opponentMember.userId; - this._addCall(call, userId); + const existingParticipant = this.participants.find( + (p) => p.userId === userId + ); + + if (existingParticipant) { + existingParticipant.call = call; + } + + this._addCall(call); call.answer(); this.emit("call", call); }; - _addCall(call, userId) { - if (call.state === "ended") { - return; - } - - const existingCall = this.participants.find( - (p) => !p.local && p.call && p.call.callId === call.callId - ); - - if (existingCall) { - return; - } + _addCall(call) { + const userId = call.opponentMember.userId; this.participants.push({ userId, @@ -450,3 +458,221 @@ export class ConferenceCallManager extends EventEmitter { localStorage.removeItem("matrix-auth-store"); } } + +/** + * - incoming + * - you have not joined + * - you have joined + * - initial room members + * - new room members + */ + +class ConferenceCallManager2 extends EventEmitter { + constructor(client) { + super(); + + this.client = client; + + this.room = null; + + // The session id is used to re-initiate calls if the user's participant + // session id has changed + this.sessionId = randomString(16); + + this._memberParticipantStateTimeout = null; + + // Whether or not we have entered the conference call. + this.entered = false; + + // The MatrixCalls that were picked up by the Call.incoming listener, + // before the user entered the conference call. + this._incomingCallQueue = []; + + // A representation of the conference call data for each room member + // that has entered the call. + this.participants = []; + + this.localParticipant = null; + + this.client.on("RoomState.members", this._onRoomStateMembers); + this.client.on("Call.incoming", this._onIncomingCall); + } + + async enter(roomId, timeout = 30000) { + // Ensure that we have joined the provided room. + await this.client.joinRoom(roomId); + + // Get the room info, wait if it hasn't been fetched yet. + // Timeout after 30 seconds or the provided duration. + const room = await new Promise((resolve, reject) => { + const initialRoom = manager.client.getRoom(roomId); + + if (initialRoom) { + resolve(initialRoom); + return; + } + + const roomTimeout = setTimeout(() => { + reject(new Error("Room could not be found.")); + }, timeout); + + const roomCallback = (room) => { + if (room && room.roomId === roomId) { + this.client.removeListener("Room", roomCallback); + clearTimeout(roomTimeout); + resolve(room); + } + }; + + this.client.on("Room", roomCallback); + }); + + this.room = room; + + // Ensure that this room is marked as a conference room so clients can react appropriately + const activeConf = room.currentState + .getStateEvents(CONF_ROOM, "") + ?.getContent()?.active; + + if (!activeConf) { + this.client.sendStateEvent(room.roomId, CONF_ROOM, { active: true }, ""); + } + + // Request permissions and get the user's webcam/mic stream if we haven't yet. + const userId = this.client.getUserId(); + const stream = await this.client.getLocalVideoStream(); + + this.localParticipant = { + userId, + stream, + }; + + this.participants.push(this.localParticipant); + this.emit("debugstate", userId, null, "you"); + + // Announce to the other room members that we have entered the room. + // Continue doing so every PARTICIPANT_TIMEOUT ms + this._updateMemberParticipantState(); + + // Set up participants for the members currently in the room. + // Other members will be picked up by the RoomState.members event. + const initialMembers = room.getMembers(); + + for (const member of initialMembers) { + this._onMemberChanged(member); + } + + this.entered = true; + } + + _updateMemberParticipantState = () => { + const userId = this.client.getUserId(); + const currentMemberState = this.room.currentState.getStateEvents( + "m.room.member", + userId + ); + + this.client.sendStateEvent( + this.room.roomId, + "m.room.member", + { + ...currentMemberState.getContent(), + [CONF_PARTICIPANT]: { + sessionId: this.sessionId, + expiresAt: new Date().getTime() + PARTICIPANT_TIMEOUT * 2, + }, + }, + userId + ); + + this._memberParticipantStateTimeout = setTimeout( + this._updateMemberParticipantState, + PARTICIPANT_TIMEOUT + ); + }; + + /** + * Call Setup + * + * There are two different paths for calls to be created: + * 1. Incoming calls triggered by the Call.incoming event. + * 2. Outgoing calls to the initial members of a room or new members + * as they are observed by the RoomState.members event. + */ + + _onIncomingCall = (call) => { + // The incoming calls may be for another room, which we will ignore. + if (call.roomId !== this.room.roomId) { + return; + } + + // If we haven't entered yet, add the call to a queue which we'll use later. + if (!this.entered) { + this._incomingCallQueue.push(call); + return; + } + + // Check if the user calling has an existing participant and use this call instead. + const userId = call.opponentMember.userId; + const existingParticipant = manager.participants.find( + (p) => p.userId === userId + ); + + if (existingParticipant) { + // This also fires the hangup event and triggers those side-effects + existingParticipant.call.hangup("user_hangup", false); + existingParticipant.call = call; + } + + call.answer(); + + this.emit("call", call); + }; + + _onRoomStateMembers = (_event, _state, member) => { + this._onMemberChanged(member); + }; + + _onMemberChanged = (member) => { + // Don't process new members until we've entered the conference call. + if (!this.entered) { + return; + } + + // The member events may be received for another room, which we will ignore. + if (member.roomId !== this.room.roomId) { + return; + } + + const localUserId = this.client.getUserId(); + + if (member.userId === localUserId) { + return; + } + + const memberStateEvent = this.room.currentState.getStateEvents( + "m.room.member", + member.userId + ); + const { expiresAt, sessionId } = + memberStateEvent.getContent()[CONF_PARTICIPANT]; + + const now = new Date().getTime(); + + if (expiresAt < now) { + this.emit("debugstate", member.userId, null, "inactive"); + return; + } + + // Check the session id and expiration time of the existing participant to see if we should + // hang up the existing call and create a new one or ignore the changed member. + const participant = this.participants.find((p) => p.userId === userId); + + if (participant && participant.sessionId !== sessionId) { + this.emit("debugstate", member.userId, null, "inactive"); + participant.call.hangup("user_hangup", false); + } + + this.emit("call", call); + }; +} diff --git a/src/randomstring.js b/src/randomstring.js new file mode 100644 index 00000000..85a5bec9 --- /dev/null +++ b/src/randomstring.js @@ -0,0 +1,42 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; +const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const DIGITS = "0123456789"; + +export function randomString(len) { + return randomStringFrom(len, UPPERCASE + LOWERCASE + DIGITS); +} + +export function randomLowercaseString(len) { + return randomStringFrom(len, LOWERCASE); +} + +export function randomUppercaseString(len) { + return randomStringFrom(len, UPPERCASE); +} + +function randomStringFrom(len, chars) { + let ret = ""; + + for (let i = 0; i < len; ++i) { + ret += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + return ret; +}