Moving to matrix-js-sdk

This commit is contained in:
Robert Long 2021-09-10 12:20:17 -07:00
parent 4d7e5583eb
commit d813509541
13 changed files with 821 additions and 2519 deletions

View File

@ -8,7 +8,6 @@
<script>
window.global = window;
</script>
<script src="/browser-matrix.js"></script>
</head>
<body>
<div id="root"></div>

1110
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@
"color-hash": "^2.0.1",
"events": "^3.3.0",
"lodash-move": "^1.1.1",
"matrix-js-sdk": "^12.0.1",
"matrix-js-sdk": "file:../matrix-js-sdk",
"re-resizable": "^6.9.0",
"react": "^17.0.0",
"react-dom": "^17.0.0",

View File

@ -22,13 +22,14 @@ import {
Redirect,
useLocation,
} from "react-router-dom";
import { useConferenceCallManager } from "./ConferenceCallManagerHooks";
import { useClient } from "./ConferenceCallManagerHooks";
import { Home } from "./Home";
import { Room, RoomAuth } from "./Room";
import { Room } from "./Room";
import { GridDemo } from "./GridDemo";
import { RegisterPage } from "./RegisterPage";
import { LoginPage } from "./LoginPage";
import { Center } from "./Layout";
import { GuestAuthPage } from "./GuestAuthPage";
export default function App() {
const { protocol, host } = window.location;
@ -37,12 +38,12 @@ export default function App() {
const {
loading,
authenticated,
error,
manager,
client,
login,
loginAsGuest,
logout,
registerGuest,
register,
} = useConferenceCallManager(homeserverUrl);
} = useClient(homeserverUrl);
return (
<Router>
@ -54,19 +55,19 @@ export default function App() {
) : (
<Switch>
<AuthenticatedRoute authenticated={authenticated} exact path="/">
<Home manager={manager} error={error} />
<Home client={client} onLogout={logout} />
</AuthenticatedRoute>
<Route exact path="/login">
<LoginPage onLogin={login} error={error} />
<LoginPage onLogin={login} />
</Route>
<Route exact path="/register">
<RegisterPage onRegister={register} error={error} />
<RegisterPage onRegister={register} />
</Route>
<Route path="/room/:roomId">
{authenticated ? (
<Room manager={manager} error={error} />
<Room client={client} />
) : (
<RoomAuth error={error} onLoginAsGuest={loginAsGuest} />
<GuestAuthPage onRegisterGuest={registerGuest} />
)}
</Route>
<Route exact path="/grid">

View File

@ -1,981 +0,0 @@
/*
Copyright 2021 New Vector Ltd
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.
*/
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";
const PARTICIPANT_TIMEOUT = 1000 * 15;
const SPEAKING_THRESHOLD = -80;
const ACTIVE_SPEAKER_INTERVAL = 1000;
const ACTIVE_SPEAKER_SAMPLES = 8;
function waitForSync(client) {
return new Promise((resolve, reject) => {
const onSync = (state) => {
if (state === "PREPARED") {
resolve();
client.removeListener("sync", onSync);
}
};
client.on("sync", onSync);
});
}
export class ConferenceCallManager extends EventEmitter {
static async restore(homeserverUrl) {
try {
const authStore = localStorage.getItem("matrix-auth-store");
if (authStore) {
const { user_id, device_id, access_token } = JSON.parse(authStore);
const client = matrixcs.createClient({
baseUrl: homeserverUrl,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
const manager = new ConferenceCallManager(client);
await client.startClient({
// dirty hack to reduce chance of gappy syncs
// should be fixed by spotting gaps and backpaginating
initialSyncLimit: 50,
});
await waitForSync(client);
return manager;
}
} catch (err) {
localStorage.removeItem("matrix-auth-store");
throw err;
}
}
static async login(homeserverUrl, username, password) {
try {
const registrationClient = matrixcs.createClient(homeserverUrl);
const { user_id, device_id, access_token } =
await registrationClient.loginWithPassword(username, password);
const client = matrixcs.createClient({
baseUrl: homeserverUrl,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({ user_id, device_id, access_token })
);
const manager = new ConferenceCallManager(client);
await client.startClient({
// dirty hack to reduce chance of gappy syncs
// should be fixed by spotting gaps and backpaginating
initialSyncLimit: 50,
});
await waitForSync(client);
return manager;
} catch (err) {
localStorage.removeItem("matrix-auth-store");
throw err;
}
}
static async loginAsGuest(homeserverUrl, displayName) {
const registrationClient = matrixcs.createClient(homeserverUrl);
const { user_id, device_id, access_token } =
await registrationClient.registerGuest();
const client = matrixcs.createClient({
baseUrl: homeserverUrl,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
await client.setDisplayName(displayName);
client.setGuest(true);
const manager = new ConferenceCallManager(client);
await client.startClient({
// dirty hack to reduce chance of gappy syncs
// should be fixed by spotting gaps and backpaginating
initialSyncLimit: 50,
});
await waitForSync(client);
return manager;
}
static async register(homeserverUrl, username, password) {
try {
const registrationClient = matrixcs.createClient(homeserverUrl);
const { user_id, device_id, access_token } =
await registrationClient.register(username, password, null, {
type: "m.login.dummy",
});
const client = matrixcs.createClient({
baseUrl: homeserverUrl,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({ user_id, device_id, access_token })
);
const manager = new ConferenceCallManager(client);
await client.startClient({
// dirty hack to reduce chance of gappy syncs
// should be fixed by spotting gaps and backpaginating
initialSyncLimit: 50,
});
await waitForSync(client);
return manager;
} catch (err) {
localStorage.removeItem("matrix-auth-store");
throw err;
}
}
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;
this._left = 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.localVideoStream = null;
this.localParticipant = null;
this.localCallFeed = null;
this.audioMuted = false;
this.videoMuted = false;
this.activeSpeaker = null;
this._speakerMap = new Map();
this._activeSpeakerLoopTimeout = null;
this.client.on("RoomState.members", this._onRoomStateMembers);
this.client.on("Call.incoming", this._onIncomingCall);
this.callDebugger = new ConferenceCallDebugger(this);
}
async enter(roomId, timeout = 30000) {
this._left = false;
// 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 = this.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);
});
// 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._sendStateEventWithRetry(
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.getLocalVideoStream();
// It's possible to navigate to another page while the microphone permission prompt is
// open, so check to see if we've left the call.
// Only set class variables below this check so that leaveRoom properly handles
// state cleanup.
if (this._left) {
return;
}
this.room = room;
this.localParticipant = {
local: true,
userId,
displayName: this.client.getUser(this.client.getUserId()).rawDisplayName,
sessionId: this.sessionId,
call: null,
stream,
audioMuted: this.audioMuted,
videoMuted: this.videoMuted,
speaking: false,
activeSpeaker: true,
};
this.activeSpeaker = this.localParticipant;
this.participants.push(this.localParticipant);
this.emit("debugstate", userId, null, "you");
this.localCallFeed = new matrixcs.CallFeed(
stream,
this.localParticipant.userId,
"m.usermedia",
this.client,
this.room.roomId,
this.audioMuted,
this.videoMuted
);
this.localCallFeed.on("mute_state_changed", () =>
this._onCallFeedMuteStateChanged(
this.localParticipant,
this.localCallFeed
)
);
this.localCallFeed.setSpeakingThreshold(SPEAKING_THRESHOLD);
this.localCallFeed.measureVolumeActivity(true);
this.localCallFeed.on("speaking", (speaking) => {
this._onCallFeedSpeaking(this.localParticipant, speaking);
});
this.localCallFeed.on("volume_changed", (maxVolume) =>
this._onCallFeedVolumeChange(this.localParticipant, maxVolume)
);
// Announce to the other room members that we have entered the room.
// Continue doing so every PARTICIPANT_TIMEOUT ms
this._updateMemberParticipantState();
this.entered = true;
// Answer any pending incoming calls.
const incomingCallCount = this._incomingCallQueue.length;
for (let i = 0; i < incomingCallCount; i++) {
const call = this._incomingCallQueue.pop();
this._onIncomingCall(call);
}
// 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.emit("entered");
this.emit("participants_changed");
this._onActiveSpeakerLoop();
}
leaveCall() {
if (!this.entered) {
return;
}
const userId = this.client.getUserId();
const currentMemberState = this.room.currentState.getStateEvents(
"m.room.member",
userId
);
this._sendStateEventWithRetry(
this.room.roomId,
"m.room.member",
{
...currentMemberState.getContent(),
[CONF_PARTICIPANT]: null,
},
userId
);
for (const participant of this.participants) {
if (!participant.local && participant.call) {
participant.call.hangup("user_hangup", false);
}
}
this.client.stopLocalMediaStream();
this.localVideoStream = null;
this.localCallFeed.dispose();
this.localCallFeed = null;
this.room = null;
this.entered = false;
this._left = true;
this.participants = [];
this.localParticipant = null;
this.activeSpeaker = null;
this.audioMuted = false;
this.videoMuted = false;
clearTimeout(this._memberParticipantStateTimeout);
clearTimeout(this._activeSpeakerLoopTimeout);
this._speakerMap.clear();
this.emit("participants_changed");
this.emit("left");
}
async getLocalVideoStream() {
if (this.localVideoStream) {
return this.localVideoStream;
}
const stream = await this.client.getLocalVideoStream();
this.localVideoStream = stream;
return stream;
}
setAudioMuted(muted) {
this.audioMuted = muted;
if (this.localCallFeed) {
this.localCallFeed.setAudioMuted(muted);
}
const localStream = this.localVideoStream;
if (localStream) {
for (const track of localStream.getTracks()) {
if (track.kind === "audio") {
track.enabled = !this.audioMuted;
}
}
}
for (let participant of this.participants) {
const call = participant.call;
if (
call &&
call.localUsermediaStream &&
call.isMicrophoneMuted() !== this.audioMuted
) {
call.setMicrophoneMuted(this.audioMuted);
}
}
this.emit("participants_changed");
}
setVideoMuted(muted) {
this.videoMuted = muted;
if (this.localCallFeed) {
this.localCallFeed.setVideoMuted(muted);
}
const localStream = this.localVideoStream;
if (localStream) {
for (const track of localStream.getTracks()) {
if (track.kind === "video") {
track.enabled = !this.videoMuted;
}
}
}
for (let participant of this.participants) {
const call = participant.call;
if (
call &&
call.localUsermediaStream &&
call.isLocalVideoMuted() !== this.videoMuted
) {
call.setLocalVideoMuted(this.videoMuted);
}
}
this.emit("participants_changed");
}
logout() {
localStorage.removeItem("matrix-auth-store");
}
/**
* Call presence
*/
_updateMemberParticipantState = () => {
const userId = this.client.getUserId();
const currentMemberState = this.room.currentState.getStateEvents(
"m.room.member",
userId
);
this._sendStateEventWithRetry(
this.room.roomId,
"m.room.member",
{
...currentMemberState.getContent(),
[CONF_PARTICIPANT]: {
sessionId: this.sessionId,
expiresAt: new Date().getTime() + PARTICIPANT_TIMEOUT * 2,
},
},
userId
);
const now = new Date().getTime();
for (const participant of this.participants) {
if (participant.local) {
continue;
}
const memberStateEvent = this.room.currentState.getStateEvents(
"m.room.member",
participant.userId
);
const memberStateContent = memberStateEvent.getContent();
if (
!memberStateContent ||
!memberStateContent[CONF_PARTICIPANT] ||
typeof memberStateContent[CONF_PARTICIPANT] !== "object" ||
(memberStateContent[CONF_PARTICIPANT].expiresAt &&
memberStateContent[CONF_PARTICIPANT].expiresAt < now)
) {
this.emit("debugstate", participant.userId, null, "inactive");
if (participant.call) {
// NOTE: This should remove the participant on the next tick
// since matrix-js-sdk awaits a promise before firing user_hangup
participant.call.hangup("user_hangup", false);
}
}
}
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) => {
// 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;
}
// The incoming calls may be for another room, which we will ignore.
if (call.roomId !== this.room.roomId) {
return;
}
if (call.state !== "ringing") {
console.warn("Incoming call no longer in ringing state. Ignoring.");
return;
}
// Get the remote video stream if it exists.
const remoteFeed = call.getRemoteFeeds()[0];
const stream = remoteFeed && remoteFeed.stream;
const audioMuted = remoteFeed ? remoteFeed.isAudioMuted() : false;
const videoMuted = remoteFeed ? remoteFeed.isVideoMuted() : false;
const userId = call.opponentMember.userId;
const memberStateEvent = this.room.currentState.getStateEvents(
"m.room.member",
userId
);
const memberStateContent = memberStateEvent.getContent();
if (!memberStateContent || !memberStateContent[CONF_PARTICIPANT]) {
call.reject();
return;
}
const { sessionId } = memberStateContent[CONF_PARTICIPANT];
// Check if the user calling has an existing participant and use this call instead.
const existingParticipant = this.participants.find(
(p) => p.userId === userId
);
let participant;
console.log(call.opponentMember);
if (existingParticipant) {
participant = existingParticipant;
// This also fires the hangup event and triggers those side-effects
existingParticipant.call.hangup("replaced", false);
existingParticipant.call = call;
existingParticipant.stream = stream;
existingParticipant.audioMuted = audioMuted;
existingParticipant.videoMuted = videoMuted;
existingParticipant.speaking = false;
existingParticipant.activeSpeaker = false;
existingParticipant.sessionId = sessionId;
} else {
participant = {
local: false,
userId,
displayName: call.opponentMember.rawDisplayName,
sessionId,
call,
stream,
audioMuted,
videoMuted,
speaking: false,
activeSpeaker: false,
};
this.participants.push(participant);
}
if (remoteFeed) {
remoteFeed.on("mute_state_changed", () =>
this._onCallFeedMuteStateChanged(participant, remoteFeed)
);
remoteFeed.setSpeakingThreshold(SPEAKING_THRESHOLD);
remoteFeed.measureVolumeActivity(true);
remoteFeed.on("speaking", (speaking) => {
this._onCallFeedSpeaking(participant, speaking);
});
remoteFeed.on("volume_changed", (maxVolume) =>
this._onCallFeedVolumeChange(participant, maxVolume)
);
}
call.on("state", (state) =>
this._onCallStateChanged(participant, call, state)
);
call.on("feeds_changed", () => this._onCallFeedsChanged(participant, call));
call.on("replaced", (newCall) =>
this._onCallReplaced(participant, call, newCall)
);
call.on("hangup", () => this._onCallHangup(participant, call));
call.answer();
this.emit("call", call);
this.emit("participants_changed");
};
_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;
}
// Don't process your own member.
const localUserId = this.client.getUserId();
if (member.userId === localUserId) {
return;
}
// Get the latest member participant state event.
const memberStateEvent = this.room.currentState.getStateEvents(
"m.room.member",
member.userId
);
const memberStateContent = memberStateEvent.getContent();
if (!memberStateContent) {
return;
}
const participantInfo = memberStateContent[CONF_PARTICIPANT];
if (!participantInfo || typeof participantInfo !== "object") {
return;
}
const { expiresAt, sessionId } = participantInfo;
// If the participant state has expired, ignore this user.
const now = new Date().getTime();
if (expiresAt < now) {
this.emit("debugstate", member.userId, null, "inactive");
return;
}
// If there is an existing participant for this member check the session id.
// If the session id changed then we can hang up the old call and start a new one.
// Otherwise, ignore the member change event because we already have an active participant.
let participant = this.participants.find((p) => p.userId === member.userId);
if (participant) {
if (participant.sessionId !== sessionId) {
this.emit("debugstate", member.userId, null, "inactive");
participant.call.hangup("replaced", false);
} else {
return;
}
}
// Only initiate a call with a user who has a userId that is lexicographically
// less than your own. Otherwise, that user will call you.
if (member.userId < localUserId) {
this.emit("debugstate", member.userId, null, "waiting for invite");
return;
}
const call = this.client.createCall(this.room.roomId, member.userId);
if (participant) {
participant.sessionId = sessionId;
participant.call = call;
participant.stream = null;
participant.audioMuted = false;
participant.videoMuted = false;
participant.speaking = false;
participant.activeSpeaker = false;
} else {
participant = {
local: false,
userId: member.userId,
displayName: member.rawDisplayName,
sessionId,
call,
stream: null,
audioMuted: false,
videoMuted: false,
speaking: false,
activeSpeaker: false,
};
// TODO: Should we wait until the call has been answered to push the participant?
// Or do we hide the participant until their stream is live?
// Does hiding a participant without a stream present a privacy problem because
// a participant without a stream can still listen in on other user's streams?
this.participants.push(participant);
}
call.on("state", (state) =>
this._onCallStateChanged(participant, call, state)
);
call.on("feeds_changed", () => this._onCallFeedsChanged(participant, call));
call.on("replaced", (newCall) =>
this._onCallReplaced(participant, call, newCall)
);
call.on("hangup", () => this._onCallHangup(participant, call));
call.placeVideoCall().then(() => {
this.emit("call", call);
});
this.emit("participants_changed");
};
/**
* Call Event Handlers
*/
_onCallStateChanged = (participant, call, state) => {
if (
call.localUsermediaStream &&
call.isMicrophoneMuted() !== this.audioMuted
) {
call.setMicrophoneMuted(this.audioMuted);
}
if (
call.localUsermediaStream &&
call.isLocalVideoMuted() !== this.videoMuted
) {
call.setLocalVideoMuted(this.videoMuted);
}
this.emit("debugstate", participant.userId, call.callId, state);
};
_onCallFeedsChanged = (participant, call) => {
const remoteFeed = call.getRemoteFeeds()[0];
const stream = remoteFeed && remoteFeed.stream;
const audioMuted = remoteFeed ? remoteFeed.isAudioMuted() : false;
const videoMuted = remoteFeed ? remoteFeed.isVideoMuted() : false;
if (remoteFeed && participant.stream !== stream) {
participant.stream = stream;
participant.audioMuted = audioMuted;
participant.videoMuted = videoMuted;
remoteFeed.on("mute_state_changed", () =>
this._onCallFeedMuteStateChanged(participant, remoteFeed)
);
remoteFeed.setSpeakingThreshold(SPEAKING_THRESHOLD);
remoteFeed.measureVolumeActivity(true);
remoteFeed.on("speaking", (speaking) => {
this._onCallFeedSpeaking(participant, speaking);
});
remoteFeed.on("volume_changed", (maxVolume) =>
this._onCallFeedVolumeChange(participant, maxVolume)
);
this._onCallFeedMuteStateChanged(participant, remoteFeed);
}
};
_onCallFeedMuteStateChanged = (participant, feed) => {
participant.audioMuted = feed.isAudioMuted();
participant.videoMuted = feed.isVideoMuted();
if (participant.audioMuted) {
this._speakerMap.set(
participant.userId,
Array(ACTIVE_SPEAKER_SAMPLES).fill(-Infinity)
);
}
this.emit("participants_changed");
};
_onCallFeedSpeaking = (participant, speaking) => {
participant.speaking = speaking;
this.emit("participants_changed");
};
_onCallFeedVolumeChange = (participant, maxVolume) => {
if (!this._speakerMap.has(participant.userId)) {
this._speakerMap.set(
participant.userId,
Array(ACTIVE_SPEAKER_SAMPLES).fill(-Infinity)
);
}
const volumeArr = this._speakerMap.get(participant.userId);
volumeArr.shift();
volumeArr.push(maxVolume);
};
_onActiveSpeakerLoop = () => {
let topAvg;
let activeSpeakerId;
for (const [userId, volumeArr] of this._speakerMap) {
let total = 0;
for (let i = 0; i < volumeArr.length; i++) {
const volume = volumeArr[i];
total += Math.max(volume, SPEAKING_THRESHOLD);
}
const avg = total / ACTIVE_SPEAKER_SAMPLES;
if (!topAvg) {
topAvg = avg;
activeSpeakerId = userId;
} else if (avg > topAvg) {
topAvg = avg;
activeSpeakerId = userId;
}
}
if (activeSpeakerId && topAvg > SPEAKING_THRESHOLD) {
const nextActiveSpeaker = this.participants.find(
(p) => p.userId === activeSpeakerId
);
if (nextActiveSpeaker && this.activeSpeaker !== nextActiveSpeaker) {
this.activeSpeaker.activeSpeaker = false;
nextActiveSpeaker.activeSpeaker = true;
this.activeSpeaker = nextActiveSpeaker;
this.emit("participants_changed");
}
}
this._activeSpeakerLoopTimeout = setTimeout(
this._onActiveSpeakerLoop,
ACTIVE_SPEAKER_INTERVAL
);
};
_onCallReplaced = (participant, call, newCall) => {
participant.call = newCall;
newCall.on("state", (state) =>
this._onCallStateChanged(participant, newCall, state)
);
newCall.on("feeds_changed", () =>
this._onCallFeedsChanged(participant, newCall)
);
newCall.on("replaced", (nextCall) =>
this._onCallReplaced(participant, newCall, nextCall)
);
newCall.on("hangup", () => this._onCallHangup(participant, newCall));
const remoteFeed = newCall.getRemoteFeeds()[0];
participant.stream = remoteFeed ? remoteFeed.stream : null;
participant.audioMuted = remoteFeed ? remoteFeed.isAudioMuted() : false;
participant.videoMuted = remoteFeed ? remoteFeed.isVideoMuted() : false;
if (remoteFeed) {
remoteFeed.on("mute_state_changed", () =>
this._onCallFeedMuteStateChanged(participant, remoteFeed)
);
remoteFeed.setSpeakingThreshold(SPEAKING_THRESHOLD);
remoteFeed.measureVolumeActivity(true);
remoteFeed.on("speaking", (speaking) => {
this._onCallFeedSpeaking(participant, speaking);
});
remoteFeed.on("volume_changed", (maxVolume) =>
this._onCallFeedVolumeChange(participant, maxVolume)
);
}
this.emit("call", newCall);
this.emit("participants_changed");
};
_onCallHangup = (participant, call) => {
if (call.hangupReason === "replaced") {
return;
}
const participantIndex = this.participants.indexOf(participant);
if (participantIndex === -1) {
return;
}
this.participants.splice(participantIndex, 1);
if (this.activeSpeaker === participant && this.participants.length > 0) {
this.activeSpeaker = this.participants[0];
this.activeSpeaker.activeSpeaker = true;
}
this._speakerMap.delete(participant.userId);
this.emit("participants_changed");
};
/**
* Utils
*/
_sendStateEventWithRetry(
roomId,
eventType,
content,
stateKey,
callback,
maxAttempts = 5
) {
const sendStateEventWithRetry = async (attempt = 0) => {
try {
return await this.client.sendStateEvent(
roomId,
eventType,
content,
stateKey,
callback
);
} catch (error) {
if (attempt >= maxAttempts) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve(), 5));
return sendStateEventWithRetry(attempt + 1);
}
};
return sendStateEventWithRetry();
}
}

View File

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { useCallback, useEffect, useState } from "react";
import { ConferenceCallManager } from "./ConferenceCallManager";
import { useCallback, useEffect, useRef, useState } from "react";
import matrix from "matrix-js-sdk";
// https://stackoverflow.com/a/9039885
function isIOS() {
@ -33,289 +33,230 @@ function isIOS() {
);
}
export function useConferenceCallManager(homeserverUrl) {
const [{ loading, authenticated, manager, error }, setState] = useState({
function waitForSync(client) {
return new Promise((resolve, reject) => {
const onSync = (state) => {
if (state === "PREPARED") {
resolve();
client.removeListener("sync", onSync);
}
};
client.on("sync", onSync);
});
}
async function initClient(clientOptions, guest) {
const client = matrix.createClient(clientOptions);
if (guest) {
client.setGuest(true);
}
await client.startClient({
// dirty hack to reduce chance of gappy syncs
// should be fixed by spotting gaps and backpaginating
initialSyncLimit: 50,
});
await waitForSync(client);
return client;
}
export async function fetchRoom(client, roomId, join, timeout = 5000) {
let room = client.getRoom(roomId);
if (room) {
return room;
}
if (join) {
room = await client.joinRoom(roomId);
if (room) {
return room;
}
}
return new Promise((resolve, reject) => {
let timeoutId;
function onRoom(room) {
if (room && room.roomId === roomId) {
clearTimeout(timeoutId);
client.removeListener("Room", onRoom);
resolve(room);
}
}
const room = client.getRoom(roomId);
if (room) {
resolve(room);
}
client.on("Room", onRoom);
if (timeout) {
timeoutId = setTimeout(() => {
client.removeListener("Room", onRoom);
reject(new Error("Fetching room timed out."));
}, timeout);
}
});
}
export function useClient(homeserverUrl) {
const [{ loading, authenticated, client }, setState] = useState({
loading: true,
authenticated: false,
manager: undefined,
error: undefined,
client: undefined,
});
useEffect(() => {
ConferenceCallManager.restore(homeserverUrl)
.then((manager) => {
setState({
manager,
loading: false,
authenticated: !!manager,
error: undefined,
});
})
.catch((err) => {
console.error(err);
async function restore() {
try {
const authStore = localStorage.getItem("matrix-auth-store");
setState({
manager: undefined,
loading: false,
authenticated: false,
error: err,
});
});
}, []);
if (authStore) {
const { user_id, device_id, access_token } = JSON.parse(authStore);
const login = useCallback(async (username, password, cb) => {
setState((prevState) => ({
...prevState,
authenticated: false,
error: undefined,
}));
const client = await initClient({
baseUrl: homeserverUrl,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
ConferenceCallManager.login(homeserverUrl, username, password)
.then((manager) => {
setState({
manager,
loading: false,
authenticated: true,
error: undefined,
});
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({ user_id, device_id, access_token })
);
if (cb) {
cb();
return client;
}
} catch (err) {
localStorage.removeItem("matrix-auth-store");
throw err;
}
}
restore()
.then((client) => {
if (client) {
setState({ client, loading: false, authenticated: true });
} else {
setState({ client: undefined, loading: false, authenticated: false });
}
})
.catch((err) => {
console.error(err);
setState({
manager: undefined,
loading: false,
authenticated: false,
error: err,
});
.catch(() => {
setState({ client: undefined, loading: false, authenticated: false });
});
}, []);
const loginAsGuest = useCallback(async (displayName) => {
setState((prevState) => ({
...prevState,
authenticated: false,
error: undefined,
}));
const login = useCallback(async (username, password) => {
try {
const registrationClient = matrix.createClient(homeserverUrl);
ConferenceCallManager.loginAsGuest(homeserverUrl, displayName)
.then((manager) => {
setState({
manager,
loading: false,
authenticated: true,
error: undefined,
});
})
.catch((err) => {
console.error(err);
const { user_id, device_id, access_token } =
await registrationClient.loginWithPassword(username, password);
setState({
manager: undefined,
loading: false,
authenticated: false,
error: err,
});
const client = await initClient({
baseUrl: homeserverUrl,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({ user_id, device_id, access_token })
);
setState({ client, loading: false, authenticated: true });
} catch (err) {
localStorage.removeItem("matrix-auth-store");
setState({ client: undefined, loading: false, authenticated: false });
throw err;
}
}, []);
const register = useCallback(async (username, password, cb) => {
setState((prevState) => ({
...prevState,
authenticated: false,
error: undefined,
}));
const registerGuest = useCallback(async (displayName) => {
try {
const registrationClient = matrix.createClient(homeserverUrl);
ConferenceCallManager.register(homeserverUrl, username, password)
.then((manager) => {
setState({
manager,
loading: false,
authenticated: true,
error: undefined,
});
const { user_id, device_id, access_token } =
await registrationClient.registerGuest({});
if (cb) {
cb();
}
})
.catch((err) => {
console.error(err);
const client = await initClient(
{
baseUrl: homeserverUrl,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
true
);
setState({
manager: undefined,
loading: false,
authenticated: false,
error: err,
});
});
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({ user_id, device_id, access_token })
);
setState({ client, loading: false, authenticated: true });
} catch (err) {
localStorage.removeItem("matrix-auth-store");
setState({ client: undefined, loading: false, authenticated: false });
throw err;
}
}, []);
useEffect(() => {
window.confManager = manager;
const register = useCallback(async (username, password) => {
try {
const registrationClient = matrix.createClient(homeserverUrl);
return () => {
window.confManager = undefined;
};
}, [manager]);
const { user_id, device_id, access_token } =
await registrationClient.register(username, password, null, {
type: "m.login.dummy",
});
const client = await initClient({
baseUrl: homeserverUrl,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({ user_id, device_id, access_token })
);
setState({ client, loading: false, authenticated: true });
} catch (err) {
localStorage.removeItem("matrix-auth-store");
setState({ client: undefined, loading: false, authenticated: false });
throw err;
}
}, []);
const logout = useCallback(() => {
localStorage.removeItem("matrix-auth-store");
setState({ client: undefined, loading: false, authenticated: false });
}, []);
return {
loading,
authenticated,
manager,
error,
client,
login,
loginAsGuest,
registerGuest,
register,
logout,
};
}
export function useVideoRoom(manager, roomId, timeout = 5000) {
const [
{
loading,
joined,
joining,
room,
participants,
error,
videoMuted,
audioMuted,
},
setState,
] = useState({
loading: true,
joining: false,
joined: false,
room: undefined,
participants: [],
error: undefined,
videoMuted: false,
audioMuted: false,
});
useEffect(() => {
setState((prevState) => ({
...prevState,
loading: true,
room: undefined,
error: undefined,
}));
const onParticipantsChanged = () => {
setState((prevState) => ({
...prevState,
participants: [...manager.participants],
}));
};
manager.on("participants_changed", onParticipantsChanged);
manager.client.joinRoom(roomId).catch((err) => {
setState((prevState) => ({ ...prevState, loading: false, error: err }));
});
let timeoutId;
function roomCallback(room) {
if (room && room.roomId === roomId) {
clearTimeout(timeoutId);
manager.client.removeListener("Room", roomCallback);
setState((prevState) => ({
...prevState,
loading: false,
room,
error: undefined,
}));
}
}
let initialRoom = manager.client.getRoom(roomId);
if (initialRoom) {
setState((prevState) => ({
...prevState,
loading: false,
room: initialRoom,
error: undefined,
}));
} else {
manager.client.on("Room", roomCallback);
timeoutId = setTimeout(() => {
setState((prevState) => ({
...prevState,
loading: false,
room: undefined,
error: new Error("Room could not be found."),
}));
manager.client.removeListener("Room", roomCallback);
}, timeout);
}
function onLeaveCall() {
setState((prevState) => ({
...prevState,
videoMuted: manager.videoMuted,
audioMuted: manager.audioMuted,
}));
}
manager.on("left", onLeaveCall);
return () => {
manager.client.removeListener("Room", roomCallback);
manager.removeListener("participants_changed", onParticipantsChanged);
clearTimeout(timeoutId);
manager.leaveCall();
manager.removeListener("left", onLeaveCall);
};
}, [manager, roomId]);
const joinCall = useCallback(() => {
if (joining || joined) {
return;
}
setState((prevState) => ({
...prevState,
joining: true,
}));
manager
.enter(roomId)
.then(() => {
setState((prevState) => ({
...prevState,
joining: false,
joined: true,
}));
})
.catch((error) => {
setState((prevState) => ({
...prevState,
joining: false,
joined: false,
error,
}));
});
}, [manager, roomId, joining, joined]);
const leaveCall = useCallback(() => {
manager.leaveCall();
setState((prevState) => ({
...prevState,
participants: [...manager.participants],
joined: false,
joining: false,
}));
}, [manager]);
function usePageUnload(callback) {
useEffect(() => {
let pageVisibilityTimeout;
@ -327,25 +268,11 @@ export function useVideoRoom(manager, roomId, timeout = 5000) {
// Wait 5 seconds before closing the page to avoid accidentally leaving
// TODO: Make this configurable?
pageVisibilityTimeout = setTimeout(() => {
manager.leaveCall();
setState((prevState) => ({
...prevState,
participants: [...manager.participants],
joined: false,
joining: false,
}));
callback();
}, 5000);
}
} else {
manager.leaveCall();
setState((prevState) => ({
...prevState,
participants: [...manager.participants],
joined: false,
joining: false,
}));
callback();
}
}
@ -363,31 +290,174 @@ export function useVideoRoom(manager, roomId, timeout = 5000) {
window.removeEventListener("beforeunload", onBeforeUnload);
clearTimeout(pageVisibilityTimeout);
};
}, [manager]);
}, []);
}
const toggleMuteAudio = useCallback(() => {
manager.setAudioMuted(!manager.audioMuted);
setState((prevState) => ({ ...prevState, audioMuted: manager.audioMuted }));
}, [manager]);
function getParticipants(groupCall) {
return [...groupCall.participants];
}
const toggleMuteVideo = useCallback(() => {
manager.setVideoMuted(!manager.videoMuted);
setState((prevState) => ({ ...prevState, videoMuted: manager.videoMuted }));
}, [manager]);
export function useGroupCall(client, roomId) {
const groupCallRef = useRef(null);
const [
{
loading,
entered,
entering,
room,
participants,
error,
microphoneMuted,
localVideoMuted,
},
setState,
] = useState({
loading: true,
entered: false,
entering: false,
room: null,
participants: [],
error: null,
microphoneMuted: false,
localVideoMuted: false,
});
const updateState = (state) =>
setState((prevState) => ({ ...prevState, ...state }));
useEffect(() => {
function onParticipantsChanged(...args) {
console.log(...args);
updateState({ participants: getParticipants(groupCallRef.current) });
}
function onLocalMuteStateChanged(microphoneMuted, localVideoMuted) {
updateState({
microphoneMuted,
localVideoMuted,
});
}
async function init() {
const room = await fetchRoom(client, roomId, true);
const groupCall = client.createGroupCall(roomId, "video");
groupCallRef.current = groupCall;
groupCall.on("active_speaker_changed", onParticipantsChanged);
groupCall.on("participants_changed", onParticipantsChanged);
groupCall.on("speaking", onParticipantsChanged);
groupCall.on("mute_state_changed", onParticipantsChanged);
groupCall.on("participant_call_replaced", onParticipantsChanged);
groupCall.on("participant_call_feeds_changed", onParticipantsChanged);
groupCall.on("local_mute_state_changed", onLocalMuteStateChanged);
updateState({
room,
loading: false,
});
}
init().catch((error) => {
if (groupCallRef.current) {
const groupCall = groupCallRef.current;
groupCall.removeListener(
"active_speaker_changed",
onParticipantsChanged
);
groupCall.removeListener("participants_changed", onParticipantsChanged);
groupCall.removeListener("speaking", onParticipantsChanged);
groupCall.removeListener("mute_state_changed", onParticipantsChanged);
groupCall.removeListener(
"participant_call_replaced",
onParticipantsChanged
);
groupCall.removeListener(
"participant_call_feeds_changed",
onParticipantsChanged
);
groupCall.removeListener(
"local_mute_state_changed",
onLocalMuteStateChanged
);
groupCall.leave();
}
updateState({ error, loading: false });
});
return () => {
if (groupCallRef.current) {
groupCallRef.current.leave();
}
};
}, [client, roomId]);
usePageUnload(() => {
if (groupCallRef.current) {
groupCallRef.current.leave();
}
});
const initLocalParticipant = useCallback(
() => groupCallRef.current.initLocalParticipant(),
[]
);
const enter = useCallback(() => {
updateState({ entering: true });
groupCallRef.current
.enter()
.then(() => {
updateState({
entered: true,
entering: false,
participants: getParticipants(groupCallRef.current),
});
})
.catch((error) => {
updateState({ error, entering: false });
});
}, []);
const leave = useCallback(() => {
groupCallRef.current.leave();
updateState({
entered: false,
participants: [],
microphoneMuted: false,
localVideoMuted: false,
});
}, []);
const toggleLocalVideoMuted = useCallback(() => {
groupCallRef.current.setLocalVideoMuted(
!groupCallRef.current.isLocalVideoMuted()
);
}, []);
const toggleMicrophoneMuted = useCallback(() => {
groupCallRef.current.setMicrophoneMuted(
!groupCallRef.current.isMicrophoneMuted()
);
}, []);
return {
loading,
joined,
joining,
room,
entered,
entering,
roomName: room ? room.name : null,
participants,
groupCall: groupCallRef.current,
microphoneMuted,
localVideoMuted,
error,
joinCall,
leaveCall,
toggleMuteVideo,
toggleMuteAudio,
videoMuted,
audioMuted,
initLocalParticipant,
enter,
leave,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
};
}
@ -440,22 +510,22 @@ function sortRooms(client, rooms) {
});
}
export function useRooms(manager) {
export function useRooms(client) {
const [rooms, setRooms] = useState([]);
useEffect(() => {
function updateRooms() {
const visibleRooms = manager.client.getVisibleRooms();
const sortedRooms = sortRooms(manager.client, visibleRooms);
const visibleRooms = client.getVisibleRooms();
const sortedRooms = sortRooms(client, visibleRooms);
setRooms(sortedRooms);
}
updateRooms();
manager.client.on("Room", updateRooms);
client.on("Room", updateRooms);
return () => {
manager.client.removeListener("Room", updateRooms);
client.removeListener("Room", updateRooms);
};
}, []);

91
src/GuestAuthPage.jsx Normal file
View File

@ -0,0 +1,91 @@
/*
Copyright 2021 New Vector Ltd
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.
*/
import React, { useState, useRef, useCallback } from "react";
import styles from "./GuestAuthPage.module.css";
import { useLocation, useHistory, Link } from "react-router-dom";
import { Header, LeftNav } from "./Header";
import { Button, FieldRow, InputField, ErrorMessage } from "./Input";
import { Center, Content, Info, Modal } from "./Layout";
export function GuestAuthPage({ onLoginAsGuest }) {
const displayNameRef = useRef();
const history = useHistory();
const location = useLocation();
const [error, setError] = useState();
const onSubmitLoginForm = useCallback(
(e) => {
e.preventDefault();
onLoginAsGuest(displayNameRef.current.value).catch(setError);
},
[onLoginAsGuest, location, history]
);
return (
<div className={styles.guestAuthPage}>
<Header>
<LeftNav />
</Header>
<Content>
<Center>
<Modal>
<h2>Login As Guest</h2>
<form onSubmit={onSubmitLoginForm}>
<FieldRow>
<InputField
type="text"
ref={displayNameRef}
placeholder="Display Name"
label="Display Name"
autoCorrect="off"
autoCapitalize="none"
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow rightAlign>
<Button type="submit">Login as guest</Button>
</FieldRow>
</form>
<Info>
<Link
to={{
pathname: "/login",
state: location.state,
}}
>
Sign in
</Link>
{" or "}
<Link
to={{
pathname: "/register",
state: location.state,
}}
>
Create account
</Link>
</Info>
</Modal>
</Center>
</Content>
</div>
);
}

View File

@ -0,0 +1,8 @@
.guestAuthPage {
position: relative;
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
overflow: hidden;
}

View File

@ -25,12 +25,12 @@ import { Center, Content, Modal } from "./Layout";
const colorHash = new ColorHash({ lightness: 0.3 });
export function Home({ manager }) {
export function Home({ client, onLogout }) {
const history = useHistory();
const roomNameRef = useRef();
const guestAccessRef = useRef();
const [createRoomError, setCreateRoomError] = useState();
const rooms = useRooms(manager);
const rooms = useRooms(client);
const onCreateRoom = useCallback(
(e) => {
@ -38,7 +38,7 @@ export function Home({ manager }) {
setCreateRoomError(undefined);
async function createRoom(name, guestAccess) {
const { room_id } = await manager.client.createRoom({
const { room_id } = await client.createRoom({
visibility: "private",
preset: "public_chat",
name,
@ -62,14 +62,14 @@ export function Home({ manager }) {
"m.sticker": 50,
},
users: {
[manager.client.getUserId()]: 100,
[client.getUserId()]: 100,
},
}
: undefined,
});
if (guestAccess) {
await manager.client.setGuestAccess(room_id, {
await client.setGuestAccess(room_id, {
allowJoin: true,
allowRead: true,
});
@ -83,27 +83,14 @@ export function Home({ manager }) {
guestAccessRef.current.checked
).catch(setCreateRoomError);
},
[manager]
);
const onLogout = useCallback(
(e) => {
e.preventDefault();
manager.logout();
location.reload();
},
[manager]
[client]
);
return (
<>
<Header>
<LeftNav />
<UserNav
signedIn={manager.client}
userName={manager.client.getUserId()}
onLogout={onLogout}
/>
<UserNav signedIn userName={client.getUserId()} onLogout={onLogout} />
</Header>
<Content>
<Center>

View File

@ -20,26 +20,25 @@ import { Header, LeftNav } from "./Header";
import { FieldRow, InputField, Button, ErrorMessage } from "./Input";
import { Center, Content, Info, Modal } from "./Layout";
export function LoginPage({ onLogin, error }) {
export function LoginPage({ onLogin }) {
const loginUsernameRef = useRef();
const loginPasswordRef = useRef();
const history = useHistory();
const location = useLocation();
const [error, setError] = useState();
const onSubmitLoginForm = useCallback(
(e) => {
e.preventDefault();
onLogin(
loginUsernameRef.current.value,
loginPasswordRef.current.value,
() => {
onLogin(loginUsernameRef.current.value, loginPasswordRef.current.value)
.then(() => {
if (location.state && location.state.from) {
history.replace(location.state.from);
} else {
history.replace("/");
}
}
);
})
.catch(setError);
},
[onLogin, location, history]
);

View File

@ -14,32 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useRef } from "react";
import React, { useCallback, useRef, useState } from "react";
import { useHistory, useLocation, Link } from "react-router-dom";
import { Header, LeftNav } from "./Header";
import { FieldRow, InputField, Button, ErrorMessage } from "./Input";
import { Center, Content, Info, Modal } from "./Layout";
export function RegisterPage({ onRegister, error }) {
export function RegisterPage({ onRegister }) {
const registerUsernameRef = useRef();
const registerPasswordRef = useRef();
const history = useHistory();
const location = useLocation();
const [error, setError] = useState();
const onSubmitRegisterForm = useCallback(
(e) => {
e.preventDefault();
onRegister(
registerUsernameRef.current.value,
registerPasswordRef.current.value,
() => {
registerPasswordRef.current.value
)
.then(() => {
if (location.state && location.state.from) {
history.replace(location.state.from);
} else {
history.replace("/");
}
}
);
})
.catch(setError);
},
[onRegister, location, history]
);

View File

@ -23,7 +23,7 @@ import React, {
} from "react";
import styles from "./Room.module.css";
import { useParams, useLocation, useHistory, Link } from "react-router-dom";
import { useVideoRoom } from "./ConferenceCallManagerHooks";
import { useGroupCall } from "./ConferenceCallManagerHooks";
import { DevTools } from "./DevTools";
import { VideoGrid } from "./VideoGrid";
import {
@ -42,25 +42,16 @@ function useQuery() {
return useMemo(() => new URLSearchParams(location.search), [location.search]);
}
export function Room({ manager }) {
const { roomId } = useParams();
function useDebugMode() {
const query = useQuery();
const {
loading,
joined,
joining,
room,
participants,
error,
joinCall,
leaveCall,
toggleMuteVideo,
toggleMuteAudio,
videoMuted,
audioMuted,
} = useVideoRoom(manager, roomId);
const debugStr = query.get("debug");
const [debug, setDebug] = useState(debugStr === "" || debugStr === "true");
const [debugMode, setDebugMode] = useState(
debugStr === "" || debugStr === "true"
);
const toggleDebugMode = useCallback(() => {
setDebugMode((prevDebugMode) => !prevDebugMode);
}, []);
useEffect(() => {
function onKeyDown(event) {
@ -68,7 +59,7 @@ export function Room({ manager }) {
document.activeElement.tagName !== "input" &&
event.code === "Backquote"
) {
setDebug((prevDebug) => !prevDebug);
toggleDebugMode();
}
}
@ -79,190 +70,237 @@ export function Room({ manager }) {
};
}, []);
return [debugMode, toggleDebugMode];
}
function useRoomLayout() {
const [layout, setLayout] = useState("gallery");
const toggleLayout = useCallback(() => {
setLayout(layout === "spotlight" ? "gallery" : "spotlight");
}, [layout]);
return (
<div className={styles.room}>
{!loading && room && (
<Header>
<LeftNav />
<CenterNav>
<h3>{room.name}</h3>
</CenterNav>
<RightNav>
{!loading && room && joined && (
<LayoutToggleButton
title={layout === "spotlight" ? "Spotlight" : "Gallery"}
layout={layout}
onClick={toggleLayout}
/>
)}
<SettingsButton
title={debug ? "Disable DevTools" : "Enable DevTools"}
on={debug}
onClick={() => setDebug((debug) => !debug)}
/>
</RightNav>
</Header>
)}
{loading && (
<div className={styles.centerMessage}>
<p>Loading room...</p>
</div>
)}
{error && <div className={styles.centerMessage}>{error.message}</div>}
{!loading && room && !joined && (
<JoinRoom
manager={manager}
joining={joining}
joinCall={joinCall}
toggleMuteVideo={toggleMuteVideo}
toggleMuteAudio={toggleMuteAudio}
videoMuted={videoMuted}
audioMuted={audioMuted}
return [layout, toggleLayout];
}
export function Room({ client }) {
const { roomId } = useParams();
const {
loading,
entered,
entering,
roomName,
participants,
groupCall,
microphoneMuted,
localVideoMuted,
error,
initLocalParticipant,
enter,
leave,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
} = useGroupCall(client, roomId);
const content = () => {
if (error) {
return <LoadingErrorView error={error} />;
}
if (loading) {
return <LoadingRoomView />;
}
if (entering) {
return <EnteringRoomView />;
}
if (!entered) {
return (
<RoomSetupView
roomName={roomName}
onInitLocalParticipant={initLocalParticipant}
onEnter={enter}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
/>
)}
{!loading && room && joined && participants.length === 0 && (
<div className={styles.centerMessage}>
<p>Waiting for other participants...</p>
</div>
)}
{!loading && room && joined && participants.length > 0 && (
<VideoGrid participants={participants} layout={layout} />
)}
{!loading && room && joined && (
<div className={styles.footer}>
<MicButton muted={audioMuted} onClick={toggleMuteAudio} />
<VideoButton enabled={videoMuted} onClick={toggleMuteVideo} />
<HangupButton onClick={leaveCall} />
</div>
)}
{debug && <DevTools manager={manager} />}
</div>
);
} else {
return (
<InRoomView
roomName={roomName}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
participants={participants}
onLeave={leave}
groupCall={groupCall}
/>
);
}
};
return <div className={styles.room}>{content()}</div>;
}
export function LoadingRoomView() {
return (
<>
<div className={styles.centerMessage}>
<p>Loading room...</p>
</div>
</>
);
}
export function RoomAuth({ onLoginAsGuest, error }) {
const displayNameRef = useRef();
const history = useHistory();
const location = useLocation();
const onSubmitLoginForm = useCallback(
(e) => {
e.preventDefault();
onLoginAsGuest(displayNameRef.current.value);
},
[onLoginAsGuest, location, history]
export function EnteringRoomView() {
return (
<>
<div className={styles.centerMessage}>
<p>Entering room...</p>
</div>
</>
);
}
export function LoadingErrorView({ error }) {
return (
<>
<div className={styles.centerMessage}>
<ErrorMessage>{error.message}</ErrorMessage>
</div>
</>
);
}
const PermissionState = {
Waiting: "waiting",
Granted: "granted",
Denied: "denied",
};
function RoomSetupView({
roomName,
onInitLocalParticipant,
onEnter,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
groupCall,
}) {
const videoRef = useRef();
const [permissionState, setPermissionState] = useState(
PermissionState.Waiting
);
useEffect(() => {
onInitLocalParticipant()
.then((localParticipant) => {
if (videoRef.current) {
videoRef.current.srcObject = localParticipant.usermediaStream;
videoRef.current.play();
setPermissionState(PermissionState.Granted);
}
})
.catch(() => {
if (videoRef.current) {
setPermissionState(PermissionState.Denied);
}
});
}, [onInitLocalParticipant]);
return (
<>
<Header>
<LeftNav />
<CenterNav>
<h3>{roomName}</h3>
</CenterNav>
</Header>
<Content>
<Center>
<Modal>
<h2>Login As Guest</h2>
<form onSubmit={onSubmitLoginForm}>
<FieldRow>
<InputField
type="text"
ref={displayNameRef}
placeholder="Display Name"
label="Display Name"
autoCorrect="off"
autoCapitalize="none"
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow rightAlign>
<Button type="submit">Login as guest</Button>
</FieldRow>
</form>
<Info>
<Link
to={{
pathname: "/login",
state: location.state,
}}
>
Sign in
</Link>
{" or "}
<Link
to={{
pathname: "/register",
state: location.state,
}}
>
Create account
</Link>
</Info>
</Modal>
</Center>
</Content>
<div className={styles.joinRoom}>
<div className={styles.preview}>
{permissionState === PermissionState.Denied && (
<p className={styles.webcamPermissions}>
Webcam permissions needed to join the call.
</p>
)}
<video ref={videoRef} muted playsInline disablePictureInPicture />
</div>
{permissionState === PermissionState.Granted && (
<div className={styles.previewButtons}>
<MicButton
muted={microphoneMuted}
onClick={toggleMicrophoneMuted}
/>
<VideoButton
enabled={localVideoMuted}
onClick={toggleLocalVideoMuted}
/>
</div>
)}
<Button
disabled={permissionState !== PermissionState.Granted}
onClick={onEnter}
>
Enter Call
</Button>
</div>
</>
);
}
function JoinRoom({
joining,
joinCall,
manager,
toggleMuteVideo,
toggleMuteAudio,
videoMuted,
audioMuted,
function InRoomView({
roomName,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
participants,
onLeave,
groupCall,
}) {
const videoRef = useRef();
const [hasPermissions, setHasPermissions] = useState(false);
const [needsPermissions, setNeedsPermissions] = useState(false);
useEffect(() => {
manager
.getLocalVideoStream()
.then((stream) => {
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.play();
setHasPermissions(true);
}
})
.catch(() => {
if (videoRef.current) {
setNeedsPermissions(true);
}
});
}, [manager]);
const [debugMode, toggleDebugMode] = useDebugMode();
const [roomLayout, toggleRoomLayout] = useRoomLayout();
return (
<div className={styles.joinRoom}>
<div className={styles.preview}>
{needsPermissions && (
<p className={styles.webcamPermissions}>
Webcam permissions needed to join the call.
</p>
)}
<video ref={videoRef} muted playsInline disablePictureInPicture />
</div>
{hasPermissions && (
<div className={styles.previewButtons}>
<MicButton muted={audioMuted} onClick={toggleMuteAudio} />
<VideoButton enabled={videoMuted} onClick={toggleMuteVideo} />
<>
<Header>
<LeftNav />
<CenterNav>
<h3>{roomName}</h3>
</CenterNav>
<RightNav>
<LayoutToggleButton
title={roomLayout === "spotlight" ? "Spotlight" : "Gallery"}
layout={roomLayout}
onClick={toggleRoomLayout}
/>
<SettingsButton
title={debugMode ? "Disable DevTools" : "Enable DevTools"}
onClick={toggleDebugMode}
/>
</RightNav>
</Header>
{participants.length === 0 ? (
<div className={styles.centerMessage}>
<p>Waiting for other participants...</p>
</div>
) : (
<VideoGrid participants={participants} layout={roomLayout} />
)}
<Button disabled={!hasPermissions || joining} onClick={joinCall}>
Join Call
</Button>
</div>
<div className={styles.footer}>
<MicButton muted={microphoneMuted} onClick={toggleMicrophoneMuted} />
<VideoButton
enabled={localVideoMuted}
onClick={toggleLocalVideoMuted}
/>
<HangupButton onClick={onLeave} />
</div>
{debugMode && <DevTools groupCall={groupCall} />}
</>
);
}

View File

@ -422,7 +422,7 @@ export function VideoGrid({ participants, layout }) {
for (const tile of tiles) {
let participant = participants.find(
(participant) => participant.userId === tile.key
(participant) => participant.member.userId === tile.key
);
let remove = false;
@ -442,7 +442,7 @@ export function VideoGrid({ participants, layout }) {
}
newTiles.push({
key: participant.userId,
key: participant.member.userId,
participant,
remove,
presenter,
@ -450,13 +450,13 @@ export function VideoGrid({ participants, layout }) {
}
for (const participant of participants) {
if (newTiles.some(({ key }) => participant.userId === key)) {
if (newTiles.some(({ key }) => participant.member.userId === key)) {
continue;
}
// Added tiles
newTiles.push({
key: participant.userId,
key: participant.member.userId,
participant,
remove: false,
presenter: layout === "spotlight" && participant.activeSpeaker,
@ -719,17 +719,19 @@ function ParticipantTile({ style, participant, remove, presenter, ...rest }) {
const videoRef = useRef();
useEffect(() => {
if (participant.stream) {
if (participant.local) {
if (participant.usermediaStream) {
// Mute the local video
// TODO: Should GroupCallParticipant have a local field?
if (!participant.call) {
videoRef.current.muted = true;
}
videoRef.current.srcObject = participant.stream;
videoRef.current.srcObject = participant.usermediaStream;
videoRef.current.play();
} else {
videoRef.current.srcObject = null;
}
}, [participant.stream]);
}, [participant.usermediaStream]);
// Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
@ -738,15 +740,15 @@ function ParticipantTile({ style, participant, remove, presenter, ...rest }) {
<animated.div className={styles.participantTile} style={style} {...rest}>
<div
className={classNames(styles.participantName, {
[styles.speaking]: participant.speaking,
[styles.speaking]: participant.usermediaStream?.speaking,
})}
>
{participant.speaking ? (
{participant.usermediaStream?.speaking ? (
<MicIcon />
) : participant.audioMuted ? (
) : participant.isAudioMuted() ? (
<MuteMicIcon className={styles.muteMicIcon} />
) : null}
<span>{participant.displayName}</span>
<span>{participant.member.rawDisplayName}</span>
</div>
{participant.videoMuted && (
<DisableVideoIcon