diff --git a/.env b/.env.example similarity index 100% rename from .env rename to .env.example diff --git a/.gitignore b/.gitignore index 6a87b455..9bafa524 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules .DS_Store +.env dist dist-ssr *.local diff --git a/.vscode/settings.json b/.vscode/settings.json index 5b43834a..fe488f68 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,21 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.insertSpaces": true, - "editor.tabSize": 2 + "editor.tabSize": 2, + "[typescriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + }, + "[javascriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + }, + "[typescript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + }, + "[javascript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + } } diff --git a/README.md b/README.md index 75c9a5e6..076cb2c5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ git clone https://github.com/vector-im/element-call.git cd element-call yarn yarn link matrix-js-sdk +cp .env.example .env yarn dev ``` diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 68676ad5..503c3f8f 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -101,12 +101,15 @@ export const ClientProvider: FC = ({ children }) => { const { user_id, device_id, access_token, passwordlessUser } = session; - const client = await initClient({ - baseUrl: defaultHomeserver, - accessToken: access_token, - userId: user_id, - deviceId: device_id, - }); + const client = await initClient( + { + baseUrl: defaultHomeserver, + accessToken: access_token, + userId: user_id, + deviceId: device_id, + }, + true + ); /* eslint-enable camelcase */ return { client, isPasswordlessUser: passwordlessUser }; diff --git a/src/auth/generateRandomName.ts b/src/auth/generateRandomName.ts index b58c1114..2232d356 100644 --- a/src/auth/generateRandomName.ts +++ b/src/auth/generateRandomName.ts @@ -19,7 +19,6 @@ import { adjectives, colors, animals, - Config, } from "unique-names-generator"; const elements = [ @@ -143,12 +142,11 @@ const elements = [ "oganesson", ]; -export function generateRandomName(config: Config): string { +export function generateRandomName(): string { return uniqueNamesGenerator({ dictionaries: [colors, adjectives, animals, elements], style: "lowerCase", length: 3, separator: "-", - ...config, }); } diff --git a/src/auth/useInteractiveLogin.ts b/src/auth/useInteractiveLogin.ts index 6379c67c..9ffa7ba6 100644 --- a/src/auth/useInteractiveLogin.ts +++ b/src/auth/useInteractiveLogin.ts @@ -57,12 +57,15 @@ export const useInteractiveLogin = () => passwordlessUser: false, }; - const client = await initClient({ - baseUrl: defaultHomeserver, - accessToken: access_token, - userId: user_id, - deviceId: device_id, - }); + const client = await initClient( + { + baseUrl: defaultHomeserver, + accessToken: access_token, + userId: user_id, + deviceId: device_id, + }, + false + ); /* eslint-enable camelcase */ return [client, session]; diff --git a/src/auth/useInteractiveRegistration.ts b/src/auth/useInteractiveRegistration.ts index af611f9d..8b8647a0 100644 --- a/src/auth/useInteractiveRegistration.ts +++ b/src/auth/useInteractiveRegistration.ts @@ -90,12 +90,15 @@ export const useInteractiveRegistration = (): [ const { user_id, access_token, device_id } = (await interactiveAuth.attemptAuth()) as any; - const client = await initClient({ - baseUrl: defaultHomeserver, - accessToken: access_token, - userId: user_id, - deviceId: device_id, - }); + const client = await initClient( + { + baseUrl: defaultHomeserver, + accessToken: access_token, + userId: user_id, + deviceId: device_id, + }, + false + ); await client.setDisplayName(displayName); diff --git a/src/auth/useRegisterPasswordlessUser.ts b/src/auth/useRegisterPasswordlessUser.ts new file mode 100644 index 00000000..73189471 --- /dev/null +++ b/src/auth/useRegisterPasswordlessUser.ts @@ -0,0 +1,59 @@ +/* +Copyright 2022 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. +*/ + +import { useCallback } from "react"; +import { randomString } from "matrix-js-sdk/src/randomstring"; + +import { useClient } from "../ClientContext"; +import { useInteractiveRegistration } from "../auth/useInteractiveRegistration"; +import { generateRandomName } from "../auth/generateRandomName"; +import { useRecaptcha } from "../auth/useRecaptcha"; + +export interface UseRegisterPasswordlessUserType { + privacyPolicyUrl: string; + registerPasswordlessUser: (displayName: string) => Promise; + recaptchaId: string; +} + +export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType { + const { setClient } = useClient(); + const [privacyPolicyUrl, recaptchaKey, register] = + useInteractiveRegistration(); + const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); + + const registerPasswordlessUser = useCallback( + async (displayName: string) => { + try { + const recaptchaResponse = await execute(); + const userName = generateRandomName(); + const [client, session] = await register( + userName, + randomString(16), + displayName, + recaptchaResponse, + true + ); + setClient(client, session); + } catch (e) { + reset(); + throw e; + } + }, + [execute, reset, register, setClient] + ); + + return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId }; +} diff --git a/src/home/RegisteredView.jsx b/src/home/RegisteredView.jsx index 1d07d1c7..b5fef745 100644 --- a/src/home/RegisteredView.jsx +++ b/src/home/RegisteredView.jsx @@ -47,7 +47,7 @@ export function RegisteredView({ client }) { setError(undefined); setLoading(true); - const roomIdOrAlias = await createRoom(client, roomName, ptt); + const [roomIdOrAlias] = await createRoom(client, roomName, ptt); if (roomIdOrAlias) { history.push(`/room/${roomIdOrAlias}`); diff --git a/src/home/UnauthenticatedView.jsx b/src/home/UnauthenticatedView.jsx index f324d504..a6d7a6ed 100644 --- a/src/home/UnauthenticatedView.jsx +++ b/src/home/UnauthenticatedView.jsx @@ -70,7 +70,7 @@ export function UnauthenticatedView() { let roomIdOrAlias; try { - roomIdOrAlias = await createRoom(client, roomName, ptt); + [roomIdOrAlias] = await createRoom(client, roomName, ptt); } catch (error) { if (error.errcode === "M_ROOM_IN_USE") { setOnFinished(() => () => { diff --git a/src/matrix-utils.ts b/src/matrix-utils.ts index 1a1a2ea6..5986954b 100644 --- a/src/matrix-utils.ts +++ b/src/matrix-utils.ts @@ -9,10 +9,6 @@ import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix"; import { ICreateClientOpts } from "matrix-js-sdk/src/matrix"; import { ClientEvent } from "matrix-js-sdk/src/client"; import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials"; -import { - GroupCallIntent, - GroupCallType, -} from "matrix-js-sdk/src/webrtc/groupCall"; import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; import { logger } from "matrix-js-sdk/src/logger"; @@ -24,6 +20,19 @@ export const defaultHomeserver = export const defaultHomeserverHost = new URL(defaultHomeserver).host; +export class CryptoStoreIntegrityError extends Error { + constructor() { + super("Crypto store data was expected, but none was found"); + } +} + +const SYNC_STORE_NAME = "element-call-sync"; +// Note that the crypto store name has changed from previous versions +// deliberately in order to force a logout for all users due to +// https://github.com/vector-im/element-call/issues/464 +// (It's a good opportunity to make the database names consistent.) +const CRYPTO_STORE_NAME = "element-call-crypto"; + function waitForSync(client: MatrixClient) { return new Promise((resolve, reject) => { const onSync = ( @@ -43,8 +52,18 @@ function waitForSync(client: MatrixClient) { }); } +/** + * Initialises and returns a new Matrix Client + * If true is passed for the 'restore' parameter, a check will be made + * to ensure that corresponding crypto data is stored and recovered. + * If the check fails, CryptoStoreIntegrityError will be thrown. + * @param clientOptions Object of options passed through to the client + * @param restore Whether the session is being restored from storage + * @returns The MatrixClient instance + */ export async function initClient( - clientOptions: ICreateClientOpts + clientOptions: ICreateClientOpts, + restore: boolean ): Promise { // TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10 window.OLM_OPTIONS = {}; @@ -62,17 +81,45 @@ export async function initClient( storeOpts.store = new IndexedDBStore({ indexedDB: window.indexedDB, localStorage, - dbName: "element-call-sync", + dbName: SYNC_STORE_NAME, workerFactory: () => new IndexedDBWorker(), }); } else if (localStorage) { storeOpts.store = new MemoryStore({ localStorage }); } + // Check whether we have crypto data store. If we are restoring a session + // from storage then we will have started the crypto store and therefore + // have generated keys for that device, so if we can't recover those keys, + // we must not continue or we'll generate new keys and anyone who saw our + // previous keys will not accept our new key. + // It's worth mentioning here that if support for indexeddb or localstorage + // appears or disappears between sessions (it happens) then the failure mode + // here will be that we'll try a different store, not find crypto data and + // fail to restore the session. An alternative would be to continue using + // whatever we were using before, but that could be confusing since you could + // enable indexeddb and but the app would still not be using it. + if (restore) { + if (indexedDB) { + const cryptoStoreExists = await IndexedDBCryptoStore.exists( + indexedDB, + CRYPTO_STORE_NAME + ); + if (!cryptoStoreExists) throw new CryptoStoreIntegrityError(); + } else if (localStorage) { + if (!LocalStorageCryptoStore.exists(localStorage)) + throw new CryptoStoreIntegrityError(); + } else { + // if we get here then we're using the memory store, which cannot + // possibly have remembered a session, so it's an error. + throw new CryptoStoreIntegrityError(); + } + } + if (indexedDB) { storeOpts.cryptoStore = new IndexedDBCryptoStore( indexedDB, - "matrix-js-sdk:crypto" + CRYPTO_STORE_NAME ); } else if (localStorage) { storeOpts.cryptoStore = new LocalStorageCryptoStore(localStorage); @@ -172,10 +219,9 @@ export function isLocalRoomId(roomId: string): boolean { export async function createRoom( client: MatrixClient, - name: string, - isPtt = false -): Promise { - const createRoomResult = await client.createRoom({ + name: string +): Promise<[string, string]> { + const result = await client.createRoom({ visibility: Visibility.Private, preset: Preset.PublicChat, name, @@ -205,16 +251,7 @@ export async function createRoom( }, }); - console.log(`Creating ${isPtt ? "PTT" : "video"} group call room`); - - await client.createGroupCall( - createRoomResult.room_id, - isPtt ? GroupCallType.Voice : GroupCallType.Video, - isPtt, - GroupCallIntent.Prompt - ); - - return fullAliasFromRoomName(name, client); + return [fullAliasFromRoomName(name, client), result.room_id]; } export function getRoomUrl(roomId: string): string { diff --git a/src/room/GroupCallLoader.jsx b/src/room/GroupCallLoader.jsx index f0741284..70791a20 100644 --- a/src/room/GroupCallLoader.jsx +++ b/src/room/GroupCallLoader.jsx @@ -30,7 +30,6 @@ export function GroupCallLoader({ client, roomId, viaServers, - true, createPtt ); diff --git a/src/room/RoomAuthView.jsx b/src/room/RoomAuthView.jsx index 4bf2303a..05613146 100644 --- a/src/room/RoomAuthView.jsx +++ b/src/room/RoomAuthView.jsx @@ -16,26 +16,21 @@ limitations under the License. import React, { useCallback, useState } from "react"; import styles from "./RoomAuthView.module.css"; -import { useClient } from "../ClientContext"; import { Button } from "../button"; import { Body, Caption, Link, Headline } from "../typography/Typography"; import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; import { useLocation } from "react-router-dom"; -import { useRecaptcha } from "../auth/useRecaptcha"; import { FieldRow, InputField, ErrorMessage } from "../input/Input"; -import { randomString } from "matrix-js-sdk/src/randomstring"; -import { useInteractiveRegistration } from "../auth/useInteractiveRegistration"; import { Form } from "../form/Form"; import { UserMenuContainer } from "../UserMenuContainer"; -import { generateRandomName } from "../auth/generateRandomName"; +import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; export function RoomAuthView() { - const { setClient } = useClient(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); - const [privacyPolicyUrl, recaptchaKey, register] = - useInteractiveRegistration(); - const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); + + const { registerPasswordlessUser, recaptchaId, privacyPolicyUrl } = + useRegisterPasswordlessUser(); const onSubmit = useCallback( (e) => { @@ -43,29 +38,13 @@ export function RoomAuthView() { const data = new FormData(e.target); const displayName = data.get("displayName"); - async function submit() { - setError(undefined); - setLoading(true); - const recaptchaResponse = await execute(); - const userName = generateRandomName(); - const [client, session] = await register( - userName, - randomString(16), - displayName, - recaptchaResponse, - true - ); - setClient(client, session); - } - - submit().catch((error) => { - console.error(error); + registerPasswordlessUser(displayName).catch((error) => { + console.error("Failed to register passwordless user", e); setLoading(false); setError(error); - reset(); }); }, - [register, reset, execute] + [registerPasswordlessUser] ); const location = useLocation(); diff --git a/src/room/RoomPage.jsx b/src/room/RoomPage.jsx index 1fe7eea6..72f6bf44 100644 --- a/src/room/RoomPage.jsx +++ b/src/room/RoomPage.jsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useMemo } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useLocation, useParams } from "react-router-dom"; import { useClient } from "../ClientContext"; import { ErrorView, LoadingView } from "../FullScreenView"; @@ -22,6 +22,7 @@ import { RoomAuthView } from "./RoomAuthView"; import { GroupCallLoader } from "./GroupCallLoader"; import { GroupCallView } from "./GroupCallView"; import { MediaHandlerProvider } from "../settings/useMediaHandler"; +import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; export function RoomPage() { const { loading, isAuthenticated, error, client, isPasswordlessUser } = @@ -29,17 +30,37 @@ export function RoomPage() { const { roomId: maybeRoomId } = useParams(); const { hash, search } = useLocation(); - const [viaServers, isEmbedded, isPtt] = useMemo(() => { + const [viaServers, isEmbedded, isPtt, displayName] = useMemo(() => { const params = new URLSearchParams(search); return [ params.getAll("via"), params.has("embed"), params.get("ptt") === "true", + params.get("displayName"), ]; }, [search]); const roomId = (maybeRoomId || hash || "").toLowerCase(); + const { registerPasswordlessUser, recaptchaId } = + useRegisterPasswordlessUser(); + const [isRegistering, setIsRegistering] = useState(false); - if (loading) { + useEffect(() => { + // If we're not already authed and we've been given a display name as + // a URL param, automatically register a passwordless user + if (!isAuthenticated && displayName) { + setIsRegistering(true); + registerPasswordlessUser(displayName).finally(() => { + setIsRegistering(false); + }); + } + }, [ + isAuthenticated, + displayName, + setIsRegistering, + registerPasswordlessUser, + ]); + + if (loading || isRegistering) { return ; } diff --git a/src/room/useLoadGroupCall.js b/src/room/useLoadGroupCall.js deleted file mode 100644 index b4ec628f..00000000 --- a/src/room/useLoadGroupCall.js +++ /dev/null @@ -1,114 +0,0 @@ -/* -Copyright 2022 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. -*/ - -import { useState, useEffect } from "react"; -import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils"; - -async function fetchGroupCall( - client, - roomIdOrAlias, - viaServers = undefined, - timeout = 5000 -) { - const { roomId } = await client.joinRoom(roomIdOrAlias, { viaServers }); - - return new Promise((resolve, reject) => { - let timeoutId; - - function onGroupCallIncoming(groupCall) { - if (groupCall && groupCall.room.roomId === roomId) { - clearTimeout(timeoutId); - client.removeListener("GroupCall.incoming", onGroupCallIncoming); - resolve(groupCall); - } - } - - const groupCall = client.getGroupCallForRoom(roomId); - - if (groupCall) { - resolve(groupCall); - } - - client.on("GroupCall.incoming", onGroupCallIncoming); - - if (timeout) { - timeoutId = setTimeout(() => { - client.removeListener("GroupCall.incoming", onGroupCallIncoming); - reject(new Error("Fetching group call timed out.")); - }, timeout); - } - }); -} - -export function useLoadGroupCall( - client, - roomId, - viaServers, - createIfNotFound, - createPtt -) { - const [state, setState] = useState({ - loading: true, - error: undefined, - groupCall: undefined, - }); - - useEffect(() => { - async function fetchOrCreateGroupCall() { - try { - const groupCall = await fetchGroupCall( - client, - roomId, - viaServers, - 30000 - ); - return groupCall; - } catch (error) { - if ( - createIfNotFound && - (error.errcode === "M_NOT_FOUND" || - (error.message && - error.message.indexOf("Failed to fetch alias") !== -1)) && - isLocalRoomId(roomId) - ) { - const roomName = roomNameFromRoomId(roomId); - await createRoom(client, roomName, createPtt); - const groupCall = await fetchGroupCall( - client, - roomId, - viaServers, - 30000 - ); - return groupCall; - } - - throw error; - } - } - - setState({ loading: true }); - - fetchOrCreateGroupCall() - .then((groupCall) => - setState((prevState) => ({ ...prevState, loading: false, groupCall })) - ) - .catch((error) => - setState((prevState) => ({ ...prevState, loading: false, error })) - ); - }, [client, roomId, state.reloadId, createIfNotFound, viaServers, createPtt]); - - return state; -} diff --git a/src/room/useLoadGroupCall.ts b/src/room/useLoadGroupCall.ts new file mode 100644 index 00000000..301ba54f --- /dev/null +++ b/src/room/useLoadGroupCall.ts @@ -0,0 +1,154 @@ +/* +Copyright 2022 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. +*/ + +import { useState, useEffect } from "react"; +import { EventType } from "matrix-js-sdk/src/@types/event"; +import { + GroupCallType, + GroupCallIntent, +} from "matrix-js-sdk/src/webrtc/groupCall"; +import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler"; +import { ClientEvent } from "matrix-js-sdk/src/client"; + +import type { MatrixClient } from "matrix-js-sdk/src/client"; +import type { Room } from "matrix-js-sdk/src/models/room"; +import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; +import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils"; + +export interface GroupCallLoadState { + loading: boolean; + error?: Error; + groupCall?: GroupCall; +} + +export const useLoadGroupCall = ( + client: MatrixClient, + roomIdOrAlias: string, + viaServers: string[], + createPtt: boolean +): GroupCallLoadState => { + const [state, setState] = useState({ loading: true }); + + useEffect(() => { + setState({ loading: true }); + + const waitForRoom = async (roomId: string): Promise => { + const room = client.getRoom(roomId); + if (room) return room; + console.log(`Room ${roomId} hasn't arrived yet: waiting`); + + const waitPromise = new Promise((resolve) => { + const onRoomEvent = async (room: Room) => { + if (room.roomId === roomId) { + client.removeListener(ClientEvent.Room, onRoomEvent); + resolve(room); + } + }; + client.on(ClientEvent.Room, onRoomEvent); + }); + + // race the promise with a timeout so we don't + // wait forever for the room + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Timed out trying to join room")); + }, 30000); + }); + + return Promise.race([waitPromise, timeoutPromise]); + }; + + const fetchOrCreateRoom = async (): Promise => { + try { + const room = await client.joinRoom(roomIdOrAlias, { viaServers }); + // wait for the room to come down the sync stream, otherwise + // client.getRoom() won't return the room. + return waitForRoom(room.roomId); + } catch (error) { + if ( + isLocalRoomId(roomIdOrAlias) && + (error.errcode === "M_NOT_FOUND" || + (error.message && + error.message.indexOf("Failed to fetch alias") !== -1)) + ) { + // The room doesn't exist, but we can create it + const [, roomId] = await createRoom( + client, + roomNameFromRoomId(roomIdOrAlias) + ); + // likewise, wait for the room + return await waitForRoom(roomId); + } else { + throw error; + } + } + }; + + const fetchOrCreateGroupCall = async (): Promise => { + const room = await fetchOrCreateRoom(); + const groupCall = client.getGroupCallForRoom(room.roomId); + + if (groupCall) return groupCall; + + if ( + room.currentState.mayClientSendStateEvent( + EventType.GroupCallPrefix, + client + ) + ) { + // The call doesn't exist, but we can create it + console.log(`Creating ${createPtt ? "PTT" : "video"} group call room`); + return await client.createGroupCall( + room.roomId, + createPtt ? GroupCallType.Voice : GroupCallType.Video, + createPtt, + GroupCallIntent.Room + ); + } + + // We don't have permission to create the call, so all we can do is wait + // for one to come in + return new Promise((resolve, reject) => { + const onGroupCallIncoming = (groupCall: GroupCall) => { + if (groupCall?.room.roomId === room.roomId) { + clearTimeout(timeout); + client.off( + GroupCallEventHandlerEvent.Incoming, + onGroupCallIncoming + ); + resolve(groupCall); + } + }; + client.on(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming); + + const timeout = setTimeout(() => { + client.off(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming); + reject(new Error("Fetching group call timed out.")); + }, 30000); + }); + }; + + fetchOrCreateGroupCall() + .then((groupCall) => + setState((prevState) => ({ ...prevState, loading: false, groupCall })) + ) + .catch((error) => + setState((prevState) => ({ ...prevState, loading: false, error })) + ); + }, [client, roomIdOrAlias, viaServers, createPtt]); + + return state; +}; diff --git a/src/video-grid/useMediaStream.ts b/src/video-grid/useMediaStream.ts index 6e34f7ec..b9cb4309 100644 --- a/src/video-grid/useMediaStream.ts +++ b/src/video-grid/useMediaStream.ts @@ -212,7 +212,12 @@ export const useSpatialMediaStream = ( const sourceRef = useRef(); useEffect(() => { - if (spatialAudio && tileRef.current && !mute) { + if ( + spatialAudio && + tileRef.current && + !mute && + stream.getAudioTracks().length > 0 + ) { if (!pannerNodeRef.current) { pannerNodeRef.current = new PannerNode(audioContext, { panningModel: "HRTF",