From 9a4479045063d9b5969ecf313687ed3b159b41dd Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 27 May 2022 10:00:14 -0400 Subject: [PATCH 01/25] TypeScriptify LoginPage --- src/auth/{LoginPage.jsx => LoginPage.tsx} | 32 ++++++++++++++--------- 1 file changed, 20 insertions(+), 12 deletions(-) rename src/auth/{LoginPage.jsx => LoginPage.tsx} (86%) diff --git a/src/auth/LoginPage.jsx b/src/auth/LoginPage.tsx similarity index 86% rename from src/auth/LoginPage.jsx rename to src/auth/LoginPage.tsx index e464172..ad71985 100644 --- a/src/auth/LoginPage.jsx +++ b/src/auth/LoginPage.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 New Vector Ltd +Copyright 2021-2022 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. @@ -14,8 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useRef, useState, useMemo } from "react"; +import React, { + FC, + FormEvent, + useCallback, + useRef, + useState, + useMemo, +} from "react"; import { useHistory, useLocation, Link } from "react-router-dom"; + import { ReactComponent as Logo } from "../icons/LogoLarge.svg"; import { useClient } from "../ClientContext"; import { FieldRow, InputField, ErrorMessage } from "../input/Input"; @@ -25,23 +33,23 @@ import styles from "./LoginPage.module.css"; import { useInteractiveLogin } from "./useInteractiveLogin"; import { usePageTitle } from "../usePageTitle"; -export function LoginPage() { +export const LoginPage: FC = () => { usePageTitle("Login"); const { setClient } = useClient(); - const [_, login] = useInteractiveLogin(); - const [homeserver, setHomeServer] = useState(defaultHomeserver); - const usernameRef = useRef(); - const passwordRef = useRef(); + const [, login] = useInteractiveLogin(); + const homeserver = defaultHomeserver; // TODO: Make this configurable + const usernameRef = useRef(); + const passwordRef = useRef(); const history = useHistory(); const location = useLocation(); const [loading, setLoading] = useState(false); - const [error, setError] = useState(); + const [error, setError] = useState(); // TODO: Handle hitting login page with authenticated client const onSubmitLoginForm = useCallback( - (e) => { + (e: FormEvent) => { e.preventDefault(); setLoading(true); @@ -60,13 +68,13 @@ export function LoginPage() { setLoading(false); }); }, - [login, location, history, homeserver] + [login, location, history, homeserver, setClient] ); const homeserverHost = useMemo(() => { try { return new URL(homeserver).host; - } catch (_error) { + } catch (error) { return defaultHomeserverHost; } }, [homeserver]); @@ -125,4 +133,4 @@ export function LoginPage() { ); -} +}; From af74228f8e141a40722b64a99ea6be6b03ba72f0 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 27 May 2022 10:37:27 -0400 Subject: [PATCH 02/25] TypeScriptify useRecaptcha --- package.json | 3 +- src/auth/{useRecaptcha.js => useRecaptcha.ts} | 59 +++++++++---------- yarn.lock | 5 ++ 3 files changed, 34 insertions(+), 33 deletions(-) rename src/auth/{useRecaptcha.js => useRecaptcha.ts} (72%) diff --git a/package.json b/package.json index fbfcfb2..e50037f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build-storybook": "build-storybook", "prettier:check": "prettier -c src", "prettier:format": "prettier -w src", - "lint:js": "eslint --max-warnings 2 src", + "lint:js": "eslint --max-warnings 0 src", "lint:types": "tsc" }, "dependencies": { @@ -31,6 +31,7 @@ "@react-stately/tree": "^3.2.0", "@sentry/react": "^6.13.3", "@sentry/tracing": "^6.13.3", + "@types/grecaptcha": "^3.0.4", "@use-gesture/react": "^10.2.11", "classnames": "^2.3.1", "color-hash": "^2.0.1", diff --git a/src/auth/useRecaptcha.js b/src/auth/useRecaptcha.ts similarity index 72% rename from src/auth/useRecaptcha.js rename to src/auth/useRecaptcha.ts index 41710bd..76856a2 100644 --- a/src/auth/useRecaptcha.js +++ b/src/auth/useRecaptcha.ts @@ -14,52 +14,49 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { randomString } from "matrix-js-sdk/src/randomstring"; import { useEffect, useCallback, useRef, useState } from "react"; +import { randomString } from "matrix-js-sdk/src/randomstring"; + +declare global { + interface Window { + mxOnRecaptchaLoaded: () => void; + } +} const RECAPTCHA_SCRIPT_URL = "https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit"; -export function useRecaptcha(sitekey) { +interface RecaptchaPromiseRef { + resolve: (response: string) => void; + reject: (error: Error) => void; +} + +export const useRecaptcha = (sitekey: string) => { const [recaptchaId] = useState(() => randomString(16)); - const promiseRef = useRef(); + const promiseRef = useRef(); useEffect(() => { - if (!sitekey) { - return; - } + if (!sitekey) return; const onRecaptchaLoaded = () => { - if (!document.getElementById(recaptchaId)) { - return; - } + if (!document.getElementById(recaptchaId)) return; window.grecaptcha.render(recaptchaId, { sitekey, size: "invisible", - callback: (response) => { - if (promiseRef.current) { - promiseRef.current.resolve(response); - } - }, - "error-callback": (error) => { - if (promiseRef.current) { - promiseRef.current.reject(error); - } - }, + callback: (response: string) => promiseRef.current?.resolve(response), + // eslint-disable-next-line @typescript-eslint/naming-convention + "error-callback": () => promiseRef.current?.reject(new Error()), }); }; - if ( - typeof window.grecaptcha !== "undefined" && - typeof window.grecaptcha.render === "function" - ) { + if (typeof window.grecaptcha?.render === "function") { onRecaptchaLoaded(); } else { window.mxOnRecaptchaLoaded = onRecaptchaLoaded; if (!document.querySelector(`script[src="${RECAPTCHA_SCRIPT_URL}"]`)) { - const scriptTag = document.createElement("script"); + const scriptTag = document.createElement("script") as HTMLScriptElement; scriptTag.src = RECAPTCHA_SCRIPT_URL; scriptTag.async = true; document.body.appendChild(scriptTag); @@ -80,7 +77,7 @@ export function useRecaptcha(sitekey) { return new Promise((resolve, reject) => { const observer = new MutationObserver((mutationsList) => { for (const item of mutationsList) { - if (item.target?.style?.visibility !== "visible") { + if ((item.target as HTMLElement)?.style?.visibility !== "visible") { reject(new Error("Recaptcha dismissed")); observer.disconnect(); return; @@ -101,7 +98,7 @@ export function useRecaptcha(sitekey) { window.grecaptcha.execute(); - const iframe = document.querySelector( + const iframe = document.querySelector( 'iframe[src*="recaptcha/api2/bframe"]' ); @@ -111,13 +108,11 @@ export function useRecaptcha(sitekey) { }); } }); - }, [recaptchaId, sitekey]); + }, [sitekey]); const reset = useCallback(() => { - if (window.grecaptcha) { - window.grecaptcha.reset(); - } - }, [recaptchaId]); + window.grecaptcha?.reset(); + }, []); return { execute, reset, recaptchaId }; -} +}; diff --git a/yarn.lock b/yarn.lock index 74d88f4..c95df5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2876,6 +2876,11 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/grecaptcha@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/grecaptcha/-/grecaptcha-3.0.4.tgz#3de601f3b0cd0298faf052dd5bd62aff64c2be2e" + integrity sha512-7l1Y8DTGXkx/r4pwU1nMVAR+yD/QC+MCHKXAyEX/7JZhwcN1IED09aZ9vCjjkcGdhSQiu/eJqcXInpl6eEEEwg== + "@types/hast@^2.0.0": version "2.3.4" resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc" From 35e2135e3cafc930870bf09dfde4db78e46b197f Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 27 May 2022 13:29:46 -0400 Subject: [PATCH 03/25] TypeScriptify useInteractiveLogin --- src/auth/LoginPage.tsx | 2 +- src/auth/useInteractiveLogin.js | 58 --------------------------------- src/auth/useInteractiveLogin.ts | 55 +++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 59 deletions(-) delete mode 100644 src/auth/useInteractiveLogin.js create mode 100644 src/auth/useInteractiveLogin.ts diff --git a/src/auth/LoginPage.tsx b/src/auth/LoginPage.tsx index ad71985..8ef1ca5 100644 --- a/src/auth/LoginPage.tsx +++ b/src/auth/LoginPage.tsx @@ -37,7 +37,7 @@ export const LoginPage: FC = () => { usePageTitle("Login"); const { setClient } = useClient(); - const [, login] = useInteractiveLogin(); + const login = useInteractiveLogin(); const homeserver = defaultHomeserver; // TODO: Make this configurable const usernameRef = useRef(); const passwordRef = useRef(); diff --git a/src/auth/useInteractiveLogin.js b/src/auth/useInteractiveLogin.js deleted file mode 100644 index a9fa804..0000000 --- a/src/auth/useInteractiveLogin.js +++ /dev/null @@ -1,58 +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 matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index"; -import { useState, useCallback } from "react"; -import { initClient, defaultHomeserver } from "../matrix-utils"; - -export function useInteractiveLogin() { - const [state, setState] = useState({ loading: false }); - - const auth = useCallback(async (homeserver, username, password) => { - const authClient = matrix.createClient(homeserver); - - const interactiveAuth = new InteractiveAuth({ - matrixClient: authClient, - busyChanged(loading) { - setState((prev) => ({ ...prev, loading })); - }, - async doRequest(_auth, _background) { - return authClient.login("m.login.password", { - identifier: { - type: "m.id.user", - user: username, - }, - password, - }); - }, - }); - - const { user_id, access_token, device_id } = - await interactiveAuth.attemptAuth(); - const session = { user_id, access_token, device_id }; - - const client = await initClient({ - baseUrl: defaultHomeserver, - accessToken: access_token, - userId: user_id, - deviceId: device_id, - }); - - return [client, session]; - }, []); - - return [state, auth]; -} diff --git a/src/auth/useInteractiveLogin.ts b/src/auth/useInteractiveLogin.ts new file mode 100644 index 0000000..b4e3ad2 --- /dev/null +++ b/src/auth/useInteractiveLogin.ts @@ -0,0 +1,55 @@ +/* +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 matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index"; + +import { initClient, defaultHomeserver } from "../matrix-utils"; + +export const useInteractiveLogin = () => + useCallback( + async (homeserver: string, username: string, password: string) => { + const authClient = matrix.createClient(homeserver); + + const interactiveAuth = new InteractiveAuth({ + matrixClient: authClient, + doRequest: () => + authClient.login("m.login.password", { + identifier: { + type: "m.id.user", + user: username, + }, + password, + }), + }); + + /* eslint-disable camelcase */ + const { user_id, access_token, device_id } = + await interactiveAuth.attemptAuth(); + const session = { user_id, access_token, device_id }; + + const client = await initClient({ + baseUrl: defaultHomeserver, + accessToken: access_token, + userId: user_id, + deviceId: device_id, + }); + /* eslint-enable camelcase */ + + return [client, session]; + }, + [] + ); From c0577130045e252c7c9c1c9ffe3d00fa0dff55fb Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 27 May 2022 16:08:03 -0400 Subject: [PATCH 04/25] TypeScriptify useInteractiveRegistration --- src/{ClientContext.jsx => ClientContext.tsx} | 152 ++++++++++-------- src/auth/RegisterPage.jsx | 2 +- ...ation.js => useInteractiveRegistration.ts} | 87 +++++----- src/home/UnauthenticatedView.jsx | 2 +- src/room/RoomAuthView.jsx | 2 +- 5 files changed, 133 insertions(+), 112 deletions(-) rename src/{ClientContext.jsx => ClientContext.tsx} (59%) rename src/auth/{useInteractiveRegistration.js => useInteractiveRegistration.ts} (57%) diff --git a/src/ClientContext.jsx b/src/ClientContext.tsx similarity index 59% rename from src/ClientContext.jsx rename to src/ClientContext.tsx index 69a4b64..09d910b 100644 --- a/src/ClientContext.jsx +++ b/src/ClientContext.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 New Vector Ltd +Copyright 2021-2022 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. @@ -15,6 +15,7 @@ limitations under the License. */ import React, { + FC, useCallback, useEffect, useState, @@ -23,17 +24,59 @@ import React, { useContext, } from "react"; import { useHistory } from "react-router-dom"; +import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + import { ErrorView } from "./FullScreenView"; import { initClient, defaultHomeserver } from "./matrix-utils"; -const ClientContext = createContext(); +declare global { + interface Window { + matrixclient: MatrixClient; + } +} -export function ClientProvider({ children }) { +export interface Session { + user_id: string; + device_id: string; + access_token: string; + passwordlessUser: boolean; + tempPassword?: string; +} + +const loadSession = (): Session => { + const data = localStorage.getItem("matrix-auth-store"); + if (data) return JSON.parse(data); + return null; +}; +const saveSession = (session: Session) => + localStorage.setItem("matrix-auth-store", JSON.stringify(session)); +const clearSession = () => localStorage.removeItem("matrix-auth-store"); + +interface ClientState { + loading: boolean; + isAuthenticated: boolean; + isPasswordlessUser: boolean; + client: MatrixClient; + userName: string; + changePassword: (password: string) => Promise; + logout: () => void; + setClient: (client: MatrixClient, session: Session) => void; +} + +const ClientContext = createContext(null); + +type ClientProviderState = Omit< + ClientState, + "changePassword" | "logout" | "setClient" +> & { error?: Error }; + +export const ClientProvider: FC = ({ children }) => { const history = useHistory(); const [ { loading, isAuthenticated, isPasswordlessUser, client, userName, error }, setState, - ] = useState({ + ] = useState({ loading: true, isAuthenticated: false, isPasswordlessUser: false, @@ -43,18 +86,16 @@ export function ClientProvider({ children }) { }); useEffect(() => { - async function restore() { + const restore = async (): Promise< + Pick + > => { try { - const authStore = localStorage.getItem("matrix-auth-store"); + const session = loadSession(); - if (authStore) { - const { - user_id, - device_id, - access_token, - passwordlessUser, - tempPassword, - } = JSON.parse(authStore); + if (session) { + /* eslint-disable camelcase */ + const { user_id, device_id, access_token, passwordlessUser } = + session; const client = await initClient({ baseUrl: defaultHomeserver, @@ -62,37 +103,26 @@ export function ClientProvider({ children }) { userId: user_id, deviceId: device_id, }); + /* eslint-enable camelcase */ - localStorage.setItem( - "matrix-auth-store", - JSON.stringify({ - user_id, - device_id, - access_token, - - passwordlessUser, - tempPassword, - }) - ); - - return { client, passwordlessUser }; + return { client, isPasswordlessUser: passwordlessUser }; } - return { client: undefined }; + return { client: undefined, isPasswordlessUser: false }; } catch (err) { console.error(err); - localStorage.removeItem("matrix-auth-store"); + clearSession(); throw err; } - } + }; restore() - .then(({ client, passwordlessUser }) => { + .then(({ client, isPasswordlessUser }) => { setState({ client, loading: false, - isAuthenticated: !!client, - isPasswordlessUser: !!passwordlessUser, + isAuthenticated: Boolean(client), + isPasswordlessUser, userName: client?.getUserIdLocalpart(), }); }) @@ -108,31 +138,23 @@ export function ClientProvider({ children }) { }, []); const changePassword = useCallback( - async (password) => { - const { tempPassword, passwordlessUser, ...existingSession } = JSON.parse( - localStorage.getItem("matrix-auth-store") - ); + async (password: string) => { + const { tempPassword, ...session } = loadSession(); await client.setPassword( { type: "m.login.password", identifier: { type: "m.id.user", - user: existingSession.user_id, + user: session.user_id, }, - user: existingSession.user_id, + user: session.user_id, password: tempPassword, }, password ); - localStorage.setItem( - "matrix-auth-store", - JSON.stringify({ - ...existingSession, - passwordlessUser: false, - }) - ); + saveSession({ ...session, passwordlessUser: false }); setState({ client, @@ -146,23 +168,23 @@ export function ClientProvider({ children }) { ); const setClient = useCallback( - (newClient, session) => { + (newClient: MatrixClient, session: Session) => { if (client && client !== newClient) { client.stopClient(); } if (newClient) { - localStorage.setItem("matrix-auth-store", JSON.stringify(session)); + saveSession(session); setState({ client: newClient, loading: false, isAuthenticated: true, - isPasswordlessUser: !!session.passwordlessUser, + isPasswordlessUser: session.passwordlessUser, userName: newClient.getUserIdLocalpart(), }); } else { - localStorage.removeItem("matrix-auth-store"); + clearSession(); setState({ client: undefined, @@ -177,29 +199,23 @@ export function ClientProvider({ children }) { ); const logout = useCallback(() => { - localStorage.removeItem("matrix-auth-store"); - window.location = "/"; + clearSession(); + history.push("/"); }, [history]); useEffect(() => { if (client) { const loadTime = Date.now(); - const onToDeviceEvent = (event) => { - if (event.getType() !== "org.matrix.call_duplicate_session") { - return; - } + const onToDeviceEvent = (event: MatrixEvent) => { + if (event.getType() !== "org.matrix.call_duplicate_session") return; const content = event.getContent(); - if (content.session_id === client.getSessionId()) { - return; - } + if (content.session_id === client.getSessionId()) return; if (content.timestamp > loadTime) { - if (client) { - client.stopClient(); - } + client?.stopClient(); setState((prev) => ({ ...prev, @@ -210,7 +226,7 @@ export function ClientProvider({ children }) { } }; - client.on("toDeviceEvent", onToDeviceEvent); + client.on(ClientEvent.ToDeviceEvent, onToDeviceEvent); client.sendToDevice("org.matrix.call_duplicate_session", { [client.getUserId()]: { @@ -219,12 +235,12 @@ export function ClientProvider({ children }) { }); return () => { - client.removeListener("toDeviceEvent", onToDeviceEvent); + client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent); }; } }, [client]); - const context = useMemo( + const context = useMemo( () => ({ loading, isAuthenticated, @@ -258,8 +274,6 @@ export function ClientProvider({ children }) { return ( {children} ); -} +}; -export function useClient() { - return useContext(ClientContext); -} +export const useClient = () => useContext(ClientContext); diff --git a/src/auth/RegisterPage.jsx b/src/auth/RegisterPage.jsx index 0bdd114..b4a0fc3 100644 --- a/src/auth/RegisterPage.jsx +++ b/src/auth/RegisterPage.jsx @@ -42,7 +42,7 @@ export function RegisterPage() { const [error, setError] = useState(); const [password, setPassword] = useState(""); const [passwordConfirmation, setPasswordConfirmation] = useState(""); - const [{ privacyPolicyUrl, recaptchaKey }, register] = + const [privacyPolicyUrl, recaptchaKey, register] = useInteractiveRegistration(); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); diff --git a/src/auth/useInteractiveRegistration.js b/src/auth/useInteractiveRegistration.ts similarity index 57% rename from src/auth/useInteractiveRegistration.js rename to src/auth/useInteractiveRegistration.ts index 8945d0e..f58c9f0 100644 --- a/src/auth/useInteractiveRegistration.js +++ b/src/auth/useInteractiveRegistration.ts @@ -14,56 +14,57 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { useState, useEffect, useCallback, useMemo } from "react"; import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + import { initClient, defaultHomeserver } from "../matrix-utils"; +import { Session } from "../ClientContext"; -export function useInteractiveRegistration() { - const [state, setState] = useState({ - privacyPolicyUrl: null, - loading: false, - }); +export const useInteractiveRegistration = (): [ + string, + string, + ( + username: string, + password: string, + displayName: string, + recaptchaResponse: string, + passwordlessUser?: boolean + ) => Promise<[MatrixClient, Session]> +] => { + const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState(); + const [recaptchaKey, setRecaptchaKey] = useState(); - const authClientRef = useRef(); + const authClient = useMemo(() => matrix.createClient(defaultHomeserver), []); useEffect(() => { - authClientRef.current = matrix.createClient(defaultHomeserver); - - authClientRef.current.registerRequest({}).catch((error) => { - const privacyPolicyUrl = - error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url; - - const recaptchaKey = error.data?.params["m.login.recaptcha"]?.public_key; - - if (privacyPolicyUrl || recaptchaKey) { - setState((prev) => ({ ...prev, privacyPolicyUrl, recaptchaKey })); - } + authClient.registerRequest({}).catch((error) => { + setPrivacyPolicyUrl( + error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url + ); + setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key); }); - }, []); + }, [authClient]); const register = useCallback( async ( - username, - password, - displayName, - recaptchaResponse, - passwordlessUser - ) => { + username: string, + password: string, + displayName: string, + recaptchaResponse: string, + passwordlessUser?: boolean + ): Promise<[MatrixClient, Session]> => { const interactiveAuth = new InteractiveAuth({ - matrixClient: authClientRef.current, - busyChanged(loading) { - setState((prev) => ({ ...prev, loading })); - }, - async doRequest(auth, _background) { - return authClientRef.current.registerRequest({ + matrixClient: authClient, + doRequest: (auth) => + authClient.registerRequest({ username, password, auth: auth || undefined, - }); - }, - stateUpdated(nextStage, status) { + }), + stateUpdated: (nextStage, status) => { if (status.error) { - throw new Error(error); + throw new Error(status.error); } if (nextStage === "m.login.terms") { @@ -79,6 +80,7 @@ export function useInteractiveRegistration() { }, }); + /* eslint-disable camelcase */ const { user_id, access_token, device_id } = await interactiveAuth.attemptAuth(); @@ -91,21 +93,26 @@ export function useInteractiveRegistration() { await client.setDisplayName(displayName); - const session = { user_id, device_id, access_token, passwordlessUser }; + const session: Session = { + user_id, + device_id, + access_token, + passwordlessUser, + }; + /* eslint-enable camelcase */ if (passwordlessUser) { session.tempPassword = password; } const user = client.getUser(client.getUserId()); - user.setRawDisplayName(displayName); user.setDisplayName(displayName); return [client, session]; }, - [] + [authClient] ); - return [state, register]; -} + return [privacyPolicyUrl, recaptchaKey, register]; +}; diff --git a/src/home/UnauthenticatedView.jsx b/src/home/UnauthenticatedView.jsx index 51e8eed..8c2653c 100644 --- a/src/home/UnauthenticatedView.jsx +++ b/src/home/UnauthenticatedView.jsx @@ -39,7 +39,7 @@ export function UnauthenticatedView() { const [callType, setCallType] = useState(CallType.Video); const [loading, setLoading] = useState(false); const [error, setError] = useState(); - const [{ privacyPolicyUrl, recaptchaKey }, register] = + const [privacyPolicyUrl, recaptchaKey, register] = useInteractiveRegistration(); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); diff --git a/src/room/RoomAuthView.jsx b/src/room/RoomAuthView.jsx index df7412a..4bf2303 100644 --- a/src/room/RoomAuthView.jsx +++ b/src/room/RoomAuthView.jsx @@ -33,7 +33,7 @@ export function RoomAuthView() { const { setClient } = useClient(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); - const [{ privacyPolicyUrl, recaptchaKey }, register] = + const [privacyPolicyUrl, recaptchaKey, register] = useInteractiveRegistration(); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); From e6960a1e15377a13c348183ac067e987f6ba4ca7 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 27 May 2022 16:48:38 -0400 Subject: [PATCH 05/25] TypeScriptify RegisterPage --- .../{RegisterPage.jsx => RegisterPage.tsx} | 77 ++++++++++--------- 1 file changed, 41 insertions(+), 36 deletions(-) rename src/auth/{RegisterPage.jsx => RegisterPage.tsx} (84%) diff --git a/src/auth/RegisterPage.jsx b/src/auth/RegisterPage.tsx similarity index 84% rename from src/auth/RegisterPage.jsx rename to src/auth/RegisterPage.tsx index b4a0fc3..fbae083 100644 --- a/src/auth/RegisterPage.jsx +++ b/src/auth/RegisterPage.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 New Vector Ltd +Copyright 2021-2022 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. @@ -14,10 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + FC, + FormEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { useHistory, useLocation } from "react-router-dom"; import { captureException } from "@sentry/react"; import { sleep } from "matrix-js-sdk/src/utils"; + import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { Button } from "../button"; import { useClient } from "../ClientContext"; @@ -30,16 +38,16 @@ import { useRecaptcha } from "./useRecaptcha"; import { Caption, Link } from "../typography/Typography"; import { usePageTitle } from "../usePageTitle"; -export function RegisterPage() { +export const RegisterPage: FC = () => { usePageTitle("Register"); const { loading, isAuthenticated, isPasswordlessUser, client, setClient } = useClient(); - const confirmPasswordRef = useRef(); + const confirmPasswordRef = useRef(); const history = useHistory(); const location = useLocation(); const [registering, setRegistering] = useState(false); - const [error, setError] = useState(); + const [error, setError] = useState(); const [password, setPassword] = useState(""); const [passwordConfirmation, setPasswordConfirmation] = useState(""); const [privacyPolicyUrl, recaptchaKey, register] = @@ -47,29 +55,18 @@ export function RegisterPage() { const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const onSubmitRegisterForm = useCallback( - (e) => { + (e: FormEvent) => { e.preventDefault(); - const data = new FormData(e.target); - const userName = data.get("userName"); - const password = data.get("password"); - const passwordConfirmation = data.get("passwordConfirmation"); + const data = new FormData(e.target as HTMLFormElement); + const userName = data.get("userName") as string; + const password = data.get("password") as string; + const passwordConfirmation = data.get("passwordConfirmation") as string; - if (password !== passwordConfirmation) { - return; - } + if (password !== passwordConfirmation) return; - async function submit() { + const submit = async () => { setRegistering(true); - let roomIds; - - if (client && isPasswordlessUser) { - const groupCalls = client.groupCallEventHandler.groupCalls.values(); - roomIds = Array.from(groupCalls).map( - (groupCall) => groupCall.room.roomId - ); - } - const recaptchaResponse = await execute(); const [newClient, session] = await register( userName, @@ -78,8 +75,11 @@ export function RegisterPage() { recaptchaResponse ); - if (roomIds) { - for (const roomId of roomIds) { + if (client && isPasswordlessUser) { + // Migrate the user's rooms + for (const groupCall of client.groupCallEventHandler.groupCalls.values()) { + const roomId = groupCall.room.roomId; + try { await newClient.joinRoom(roomId); } catch (error) { @@ -95,11 +95,11 @@ export function RegisterPage() { } setClient(newClient, session); - } + }; submit() .then(() => { - if (location.state && location.state.from) { + if (location.state?.from) { history.push(location.state.from); } else { history.push("/"); @@ -111,18 +111,23 @@ export function RegisterPage() { reset(); }); }, - [register, location, history, isPasswordlessUser, reset, execute, client] + [ + register, + location, + history, + isPasswordlessUser, + reset, + execute, + client, + setClient, + ] ); useEffect(() => { - if (!confirmPasswordRef.current) { - return; - } - if (password && passwordConfirmation && password !== passwordConfirmation) { - confirmPasswordRef.current.setCustomValidity("Passwords must match"); + confirmPasswordRef.current?.setCustomValidity("Passwords must match"); } else { - confirmPasswordRef.current.setCustomValidity(""); + confirmPasswordRef.current?.setCustomValidity(""); } }, [password, passwordConfirmation]); @@ -130,7 +135,7 @@ export function RegisterPage() { if (!loading && isAuthenticated && !isPasswordlessUser && !registering) { history.push("/"); } - }, [history, isAuthenticated, isPasswordlessUser, registering]); + }, [loading, history, isAuthenticated, isPasswordlessUser, registering]); if (loading) { return ; @@ -218,4 +223,4 @@ export function RegisterPage() { ); -} +}; From 1ff9073a1afc038fd0913d25dd3b68eb2323a4fb Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 30 May 2022 12:14:25 +0100 Subject: [PATCH 06/25] Sort call feeds consistently when choosing active speaker --- src/room/usePTT.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/room/usePTT.ts b/src/room/usePTT.ts index 5965c54..cc42140 100644 --- a/src/room/usePTT.ts +++ b/src/room/usePTT.ts @@ -30,6 +30,17 @@ function getActiveSpeakerFeed( ): CallFeed | null { const activeSpeakerFeeds = feeds.filter((f) => !f.isAudioMuted()); + // make sure the feeds are in a deterministic order so every client picks + // the same one as the active speaker + const collator = new Intl.Collator("en", { + sensitivity: "variant", + usage: "sort", + ignorePunctuation: false, + }); + activeSpeakerFeeds.sort((a: CallFeed, b: CallFeed): number => + collator.compare(a.userId, b.userId) + ); + let activeSpeakerFeed = null; let highestPowerLevel = null; for (const feed of activeSpeakerFeeds) { From 21c7bb979ee1ade3bfb18b1435ea1960dd45c282 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 30 May 2022 15:30:57 +0100 Subject: [PATCH 07/25] Convert useGroupCall to TS --- src/room/{useGroupCall.js => useGroupCall.ts} | 59 ++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) rename src/room/{useGroupCall.js => useGroupCall.ts} (82%) diff --git a/src/room/useGroupCall.js b/src/room/useGroupCall.ts similarity index 82% rename from src/room/useGroupCall.js rename to src/room/useGroupCall.ts index 51900c5..25c2179 100644 --- a/src/room/useGroupCall.js +++ b/src/room/useGroupCall.ts @@ -18,10 +18,33 @@ import { useCallback, useEffect, useState } from "react"; import { GroupCallEvent, GroupCallState, + GroupCall, } from "matrix-js-sdk/src/webrtc/groupCall"; +import { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; + import { usePageUnload } from "./usePageUnload"; -export function useGroupCall(groupCall) { +interface State { + state: GroupCallState; + calls: MatrixCall[]; + localCallFeed: CallFeed; + activeSpeaker: string; + userMediaFeeds: CallFeed[]; + error: Error; + microphoneMuted: boolean; + localVideoMuted: boolean; + screenshareFeeds: CallFeed[]; + localScreenshareFeed: CallFeed; + localDesktopCapturerSourceId: string; + isScreensharing: boolean; + requestingScreenshare: boolean; + participants: RoomMember[]; + hasLocalParticipant: boolean; +} + +export function useGroupCall(groupCall: GroupCall) { const [ { state, @@ -41,20 +64,25 @@ export function useGroupCall(groupCall) { requestingScreenshare, }, setState, - ] = useState({ + ] = useState({ state: GroupCallState.LocalCallFeedUninitialized, calls: [], + localCallFeed: null, + activeSpeaker: null, userMediaFeeds: [], + error: null, microphoneMuted: false, localVideoMuted: false, - screenshareFeeds: [], isScreensharing: false, + screenshareFeeds: [], + localScreenshareFeed: null, + localDesktopCapturerSourceId: null, requestingScreenshare: false, participants: [], hasLocalParticipant: false, }); - const updateState = (state) => + const updateState = (state: Partial) => setState((prevState) => ({ ...prevState, ...state })); useEffect(() => { @@ -75,25 +103,28 @@ export function useGroupCall(groupCall) { }); } - function onUserMediaFeedsChanged(userMediaFeeds) { + function onUserMediaFeedsChanged(userMediaFeeds: CallFeed[]): void { updateState({ userMediaFeeds: [...userMediaFeeds], }); } - function onScreenshareFeedsChanged(screenshareFeeds) { + function onScreenshareFeedsChanged(screenshareFeeds: CallFeed[]): void { updateState({ screenshareFeeds: [...screenshareFeeds], }); } - function onActiveSpeakerChanged(activeSpeaker) { + function onActiveSpeakerChanged(activeSpeaker: string): void { updateState({ activeSpeaker: activeSpeaker, }); } - function onLocalMuteStateChanged(microphoneMuted, localVideoMuted) { + function onLocalMuteStateChanged( + microphoneMuted: boolean, + localVideoMuted: boolean + ): void { updateState({ microphoneMuted, localVideoMuted, @@ -101,10 +132,10 @@ export function useGroupCall(groupCall) { } function onLocalScreenshareStateChanged( - isScreensharing, - localScreenshareFeed, - localDesktopCapturerSourceId - ) { + isScreensharing: boolean, + localScreenshareFeed: CallFeed, + localDesktopCapturerSourceId: string + ): void { updateState({ isScreensharing, localScreenshareFeed, @@ -112,13 +143,13 @@ export function useGroupCall(groupCall) { }); } - function onCallsChanged(calls) { + function onCallsChanged(calls: MatrixCall[]): void { updateState({ calls: [...calls], }); } - function onParticipantsChanged(participants) { + function onParticipantsChanged(participants: RoomMember[]): void { updateState({ participants: [...participants], hasLocalParticipant: groupCall.hasLocalParticipant(), From 1164e6f1e7282e394a2ed7409f795e73707cff2d Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 30 May 2022 15:53:44 +0100 Subject: [PATCH 08/25] Add return type too --- src/room/useGroupCall.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/room/useGroupCall.ts b/src/room/useGroupCall.ts index 25c2179..6816bcf 100644 --- a/src/room/useGroupCall.ts +++ b/src/room/useGroupCall.ts @@ -26,6 +26,30 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { usePageUnload } from "./usePageUnload"; +export interface UseGroupCallType { + state: GroupCallState; + calls: MatrixCall[]; + localCallFeed: CallFeed; + activeSpeaker: string; + userMediaFeeds: CallFeed[]; + microphoneMuted: boolean; + localVideoMuted: boolean; + error: Error; + initLocalCallFeed: () => void; + enter: () => void; + leave: () => void; + toggleLocalVideoMuted: () => void; + toggleMicrophoneMuted: () => void; + toggleScreensharing: () => void; + requestingScreenshare: boolean; + isScreensharing: boolean; + screenshareFeeds: CallFeed[]; + localScreenshareFeed: CallFeed; + localDesktopCapturerSourceId: string; + participants: RoomMember[]; + hasLocalParticipant: boolean; +} + interface State { state: GroupCallState; calls: MatrixCall[]; @@ -44,7 +68,7 @@ interface State { hasLocalParticipant: boolean; } -export function useGroupCall(groupCall: GroupCall) { +export function useGroupCall(groupCall: GroupCall): UseGroupCallType { const [ { state, From e9b963080cd3ddcd8829a21983ef2b40db9995f0 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 30 May 2022 16:28:16 +0100 Subject: [PATCH 09/25] Show when connection is lost on PTT mode --- src/room/PTTCallView.tsx | 9 +++++++-- src/room/usePTT.ts | 26 +++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/room/PTTCallView.tsx b/src/room/PTTCallView.tsx index 4b395f8..12157d2 100644 --- a/src/room/PTTCallView.tsx +++ b/src/room/PTTCallView.tsx @@ -44,8 +44,11 @@ function getPromptText( activeSpeakerIsLocalUser: boolean, talkOverEnabled: boolean, activeSpeakerUserId: string, - activeSpeakerDisplayName: string + activeSpeakerDisplayName: string, + connected: boolean ): string { + if (!connected) return "Connection Lost"; + const isTouchScreen = Boolean(window.ontouchstart !== undefined); if (showTalkOverError) { @@ -127,6 +130,7 @@ export const PTTCallView: React.FC = ({ startTalking, stopTalking, transmitBlocked, + connected, } = usePTT( client, groupCall, @@ -234,7 +238,8 @@ export const PTTCallView: React.FC = ({ activeSpeakerIsLocalUser, talkOverEnabled, activeSpeakerUserId, - activeSpeakerDisplayName + activeSpeakerDisplayName, + connected )}

{userMediaFeeds.map((callFeed) => ( diff --git a/src/room/usePTT.ts b/src/room/usePTT.ts index 5965c54..328ea44 100644 --- a/src/room/usePTT.ts +++ b/src/room/usePTT.ts @@ -15,10 +15,11 @@ limitations under the License. */ import { useCallback, useEffect, useState } from "react"; -import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client"; import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed"; import { logger } from "matrix-js-sdk/src/logger"; +import { SyncState } from "matrix-js-sdk/src/sync"; import { PlayClipFunction, PTTClipID } from "../sound/usePttSounds"; @@ -52,6 +53,11 @@ export interface PTTState { startTalking: () => void; stopTalking: () => void; transmitBlocked: boolean; + // connected is actually an indication of whether we're connected to the HS + // (ie. the client's syncing state) rather than media connection, since + // it's peer to peer so we can't really say once peer is 'disconnected' if + // there's only one other person in the call and they've lost Internet. + connected: boolean; } export const usePTT = ( @@ -211,6 +217,17 @@ export const usePTT = ( setMicMuteWrapper(true); }, [setMicMuteWrapper]); + // separate state for connected: we set it separately from other things + // in the client sync callback + const [connected, setConnected] = useState(true); + + const onClientSync = useCallback( + (syncState: SyncState) => { + setConnected(syncState !== SyncState.Error); + }, + [setConnected] + ); + useEffect(() => { function onKeyDown(event: KeyboardEvent): void { if (event.code === "Space") { @@ -245,10 +262,14 @@ export const usePTT = ( window.addEventListener("keyup", onKeyUp); window.addEventListener("blur", onBlur); + client.on(ClientEvent.Sync, onClientSync); + return () => { window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keyup", onKeyUp); window.removeEventListener("blur", onBlur); + + client.removeListener(ClientEvent.Sync, onClientSync); }; }, [ groupCall, @@ -260,6 +281,8 @@ export const usePTT = ( pttButtonHeld, enablePTTButton, setMicMuteWrapper, + client, + onClientSync, ]); const setTalkOverEnabled = useCallback((talkOverEnabled) => { @@ -278,5 +301,6 @@ export const usePTT = ( startTalking, stopTalking, transmitBlocked, + connected, }; }; From 5b3183cbd39e5154f2842d2c278abcd6dcc4f063 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 31 May 2022 10:32:54 -0400 Subject: [PATCH 10/25] Make eslint config stricter now that we can --- .eslintrc.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 1652f6d..08a438a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,9 +16,6 @@ module.exports = { "sourceType": "module", }, rules: { - // We break this rule in a few places: dial it back to a warning - // (and run with max warnings) to tolerate the existing code - "react-hooks/exhaustive-deps": ["warn"], "jsx-a11y/media-has-caption": ["off"], }, overrides: [ From 26251e1e604f8615a15329d52b4ee7b6479aaa9b Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 31 May 2022 10:33:10 -0400 Subject: [PATCH 11/25] Don't abuse useMemo for creating a MatrixClient --- src/auth/useInteractiveRegistration.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/auth/useInteractiveRegistration.ts b/src/auth/useInteractiveRegistration.ts index f58c9f0..2386b53 100644 --- a/src/auth/useInteractiveRegistration.ts +++ b/src/auth/useInteractiveRegistration.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useState, useEffect, useCallback, useMemo } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index"; import { MatrixClient } from "matrix-js-sdk/src/client"; @@ -35,16 +35,19 @@ export const useInteractiveRegistration = (): [ const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState(); const [recaptchaKey, setRecaptchaKey] = useState(); - const authClient = useMemo(() => matrix.createClient(defaultHomeserver), []); + const authClient = useRef(); + if (!authClient.current) { + authClient.current = matrix.createClient(defaultHomeserver); + } useEffect(() => { - authClient.registerRequest({}).catch((error) => { + authClient.current.registerRequest({}).catch((error) => { setPrivacyPolicyUrl( error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url ); setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key); }); - }, [authClient]); + }, []); const register = useCallback( async ( @@ -55,9 +58,9 @@ export const useInteractiveRegistration = (): [ passwordlessUser?: boolean ): Promise<[MatrixClient, Session]> => { const interactiveAuth = new InteractiveAuth({ - matrixClient: authClient, + matrixClient: authClient.current, doRequest: (auth) => - authClient.registerRequest({ + authClient.current.registerRequest({ username, password, auth: auth || undefined, @@ -111,7 +114,7 @@ export const useInteractiveRegistration = (): [ return [client, session]; }, - [authClient] + [] ); return [privacyPolicyUrl, recaptchaKey, register]; From c6b90803f8e8552733f670bbd5e04d7cf7fa8bfc Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Tue, 31 May 2022 10:43:05 -0400 Subject: [PATCH 12/25] Add spatial audio capabilities --- src/room/GroupCallView.jsx | 19 ----- src/room/InCallView.jsx | 12 ++-- src/room/LobbyView.jsx | 4 -- src/room/OverflowMenu.jsx | 10 +-- src/room/PTTCallView.tsx | 6 -- src/room/VideoPreview.jsx | 4 -- src/settings/SettingsModal.jsx | 18 ++++- src/settings/useSetting.ts | 56 +++++++++++++++ src/video-grid/VideoTile.jsx | 100 ++++++++++++++------------ src/video-grid/VideoTile.module.css | 4 ++ src/video-grid/VideoTileContainer.jsx | 11 ++- src/video-grid/useMediaStream.js | 60 ++++++++++++++++ 12 files changed, 205 insertions(+), 99 deletions(-) create mode 100644 src/settings/useSetting.ts diff --git a/src/room/GroupCallView.jsx b/src/room/GroupCallView.jsx index dcd9da3..2809b82 100644 --- a/src/room/GroupCallView.jsx +++ b/src/room/GroupCallView.jsx @@ -33,19 +33,6 @@ export function GroupCallView({ roomId, groupCall, }) { - const [showInspector, setShowInspector] = useState( - () => !!localStorage.getItem("matrix-group-call-inspector") - ); - const onChangeShowInspector = useCallback((show) => { - setShowInspector(show); - - if (show) { - localStorage.setItem("matrix-group-call-inspector", "true"); - } else { - localStorage.removeItem("matrix-group-call-inspector"); - } - }, []); - const { state, error, @@ -104,8 +91,6 @@ export function GroupCallView({ participants={participants} userMediaFeeds={userMediaFeeds} onLeave={onLeave} - setShowInspector={onChangeShowInspector} - showInspector={showInspector} /> ); } else { @@ -126,8 +111,6 @@ export function GroupCallView({ isScreensharing={isScreensharing} localScreenshareFeed={localScreenshareFeed} screenshareFeeds={screenshareFeeds} - setShowInspector={onChangeShowInspector} - showInspector={showInspector} roomId={roomId} /> ); @@ -156,8 +139,6 @@ export function GroupCallView({ localVideoMuted={localVideoMuted} toggleLocalVideoMuted={toggleLocalVideoMuted} toggleMicrophoneMuted={toggleMicrophoneMuted} - setShowInspector={onChangeShowInspector} - showInspector={showInspector} roomId={roomId} /> ); diff --git a/src/room/InCallView.jsx b/src/room/InCallView.jsx index a474963..2c6240e 100644 --- a/src/room/InCallView.jsx +++ b/src/room/InCallView.jsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useMemo } from "react"; +import React, { useCallback, useMemo, useRef } from "react"; import styles from "./InCallView.module.css"; import { HangupButton, @@ -34,6 +34,7 @@ import { useRageshakeRequestModal } from "../settings/submit-rageshake"; import { RageshakeRequestModal } from "./RageshakeRequestModal"; import { usePreventScroll } from "@react-aria/overlays"; import { useMediaHandler } from "../settings/useMediaHandler"; +import { useShowInspector } from "../settings/useSetting"; import { useModalTriggerState } from "../Modal"; const canScreenshare = "getDisplayMedia" in navigator.mediaDevices; @@ -57,14 +58,16 @@ export function InCallView({ toggleScreensharing, isScreensharing, screenshareFeeds, - setShowInspector, - showInspector, roomId, }) { usePreventScroll(); const [layout, setLayout] = useVideoGridLayout(screenshareFeeds.length > 0); const { audioOutput } = useMediaHandler(); + const [showInspector] = useShowInspector(); + + const audioContext = useRef(); + if (!audioContext.current) audioContext.current = new AudioContext(); const { modalState: feedbackModalState, modalProps: feedbackModalProps } = useModalTriggerState(); @@ -151,6 +154,7 @@ export function InCallView({ getAvatar={renderAvatar} showName={items.length > 2 || item.focused} audioOutputDevice={audioOutput} + audioContext={audioContext.current} disableSpeakingIndicator={items.length < 3} {...rest} /> @@ -169,8 +173,6 @@ export function InCallView({ diff --git a/src/room/OverflowMenu.jsx b/src/room/OverflowMenu.jsx index 281995c..c5810f0 100644 --- a/src/room/OverflowMenu.jsx +++ b/src/room/OverflowMenu.jsx @@ -31,8 +31,6 @@ import { FeedbackModal } from "./FeedbackModal"; export function OverflowMenu({ roomId, - setShowInspector, - showInspector, inCall, groupCall, showInvite, @@ -88,13 +86,7 @@ export function OverflowMenu({ )} - {settingsModalState.isOpen && ( - - )} + {settingsModalState.isOpen && } {inviteModalState.isOpen && ( )} diff --git a/src/room/PTTCallView.tsx b/src/room/PTTCallView.tsx index 4b395f8..795c249 100644 --- a/src/room/PTTCallView.tsx +++ b/src/room/PTTCallView.tsx @@ -84,8 +84,6 @@ interface Props { participants: RoomMember[]; userMediaFeeds: CallFeed[]; onLeave: () => void; - setShowInspector: (boolean) => void; - showInspector: boolean; } export const PTTCallView: React.FC = ({ @@ -97,8 +95,6 @@ export const PTTCallView: React.FC = ({ participants, userMediaFeeds, onLeave, - setShowInspector, - showInspector, }) => { const { modalState: inviteModalState, modalProps: inviteModalProps } = useModalTriggerState(); @@ -189,8 +185,6 @@ export const PTTCallView: React.FC = ({ { const { audioInput, audioInputs, @@ -41,6 +42,8 @@ export function SettingsModal({ setShowInspector, showInspector, ...rest }) { audioOutputs, setAudioOutput, } = useMediaHandler(); + const [spatialAudio, setSpatialAudio] = useSpatialAudio(); + const [showInspector, setShowInspector] = useShowInspector(); const downloadDebugLog = useDownloadDebugLog(); @@ -50,7 +53,7 @@ export function SettingsModal({ setShowInspector, showInspector, ...rest }) { isDismissable mobileFullScreen className={styles.settingsModal} - {...rest} + {...props} > )} + + setSpatialAudio(e.target.checked)} + /> + ); -} +}; diff --git a/src/settings/useSetting.ts b/src/settings/useSetting.ts new file mode 100644 index 0000000..b0db79c --- /dev/null +++ b/src/settings/useSetting.ts @@ -0,0 +1,56 @@ +/* +Copyright 2022 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 { useMemo, useState, useEffect, useCallback } from "react"; + +// Bus to notify other useSetting consumers when a setting is changed +const settingsBus = new EventEmitter(); + +// Like useState, but reads from and persists the value to localStorage +const useSetting = ( + name: string, + defaultValue: T +): [T, (value: T) => void] => { + const key = useMemo(() => `matrix-setting-${name}`, [name]); + + const [value, setValue] = useState(() => { + const item = localStorage.getItem(key); + return item == null ? defaultValue : JSON.parse(item); + }); + + useEffect(() => { + settingsBus.on(name, setValue); + return () => { + settingsBus.off(name, setValue); + }; + }, [name, setValue]); + + return [ + value, + useCallback( + (newValue: T) => { + setValue(newValue); + localStorage.setItem(key, JSON.stringify(newValue)); + settingsBus.emit(name, newValue); + }, + [name, key, setValue] + ), + ]; +}; + +export const useSpatialAudio = () => useSetting("spatial-audio", false); +export const useShowInspector = () => useSetting("show-inspector", false); diff --git a/src/video-grid/VideoTile.jsx b/src/video-grid/VideoTile.jsx index 90780f7..2dd4192 100644 --- a/src/video-grid/VideoTile.jsx +++ b/src/video-grid/VideoTile.jsx @@ -14,57 +14,63 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { forwardRef } from "react"; import { animated } from "@react-spring/web"; import classNames from "classnames"; import styles from "./VideoTile.module.css"; import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg"; import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg"; -export function VideoTile({ - className, - isLocal, - speaking, - audioMuted, - noVideo, - videoMuted, - screenshare, - avatar, - name, - showName, - mediaRef, - ...rest -}) { - return ( - - {(videoMuted || noVideo) && ( - <> -
- {avatar} - - )} - {screenshare ? ( -
- {`${name} is presenting`} -
- ) : ( - (showName || audioMuted || (videoMuted && !noVideo)) && ( -
- {audioMuted && !(videoMuted && !noVideo) && } - {videoMuted && !noVideo && } - {showName && {name}} +export const VideoTile = forwardRef( + ( + { + className, + isLocal, + speaking, + audioMuted, + noVideo, + videoMuted, + screenshare, + avatar, + name, + showName, + mediaRef, + ...rest + }, + ref + ) => { + return ( + + {(videoMuted || noVideo) && ( + <> +
+ {avatar} + + )} + {screenshare ? ( +
+ {`${name} is presenting`}
- ) - )} -