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`}
- )
- )}
-
-
- );
-}
+ ) : (
+ (showName || audioMuted || (videoMuted && !noVideo)) && (
+
+ {audioMuted && !(videoMuted && !noVideo) && }
+ {videoMuted && !noVideo && }
+ {showName && {name}}
+
+ )
+ )}
+
+
+ );
+ }
+);
diff --git a/src/video-grid/VideoTile.module.css b/src/video-grid/VideoTile.module.css
index f5e2d11..0948488 100644
--- a/src/video-grid/VideoTile.module.css
+++ b/src/video-grid/VideoTile.module.css
@@ -5,6 +5,10 @@
overflow: hidden;
cursor: pointer;
touch-action: none;
+
+ /* HACK: This has no visual effect due to the short duration, but allows the
+ JS to detect movement via the transform property for audio spatialization */
+ transition: transform 0.000000001s;
}
.videoTile * {
diff --git a/src/video-grid/VideoTileContainer.jsx b/src/video-grid/VideoTileContainer.jsx
index dddba38..a4cf400 100644
--- a/src/video-grid/VideoTileContainer.jsx
+++ b/src/video-grid/VideoTileContainer.jsx
@@ -17,7 +17,7 @@ limitations under the License.
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
import React from "react";
import { useCallFeed } from "./useCallFeed";
-import { useMediaStream } from "./useMediaStream";
+import { useSpatialMediaStream } from "./useMediaStream";
import { useRoomMemberName } from "./useRoomMemberName";
import { VideoTile } from "./VideoTile";
@@ -28,6 +28,7 @@ export function VideoTileContainer({
getAvatar,
showName,
audioOutputDevice,
+ audioContext,
disableSpeakingIndicator,
...rest
}) {
@@ -42,7 +43,12 @@ export function VideoTileContainer({
member,
} = useCallFeed(item.callFeed);
const { rawDisplayName } = useRoomMemberName(member);
- const mediaRef = useMediaStream(stream, audioOutputDevice, isLocal);
+ const [tileRef, mediaRef] = useSpatialMediaStream(
+ stream,
+ audioOutputDevice,
+ audioContext,
+ isLocal
+ );
// Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
@@ -57,6 +63,7 @@ export function VideoTileContainer({
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
name={rawDisplayName}
showName={showName}
+ ref={tileRef}
mediaRef={mediaRef}
avatar={getAvatar && getAvatar(member, width, height)}
{...rest}
diff --git a/src/video-grid/useMediaStream.js b/src/video-grid/useMediaStream.js
index 15efcff..0f8c3bf 100644
--- a/src/video-grid/useMediaStream.js
+++ b/src/video-grid/useMediaStream.js
@@ -16,6 +16,8 @@ limitations under the License.
import { useRef, useEffect } from "react";
+import { useSpatialAudio } from "../settings/useSetting";
+
export function useMediaStream(stream, audioOutputDevice, mute = false) {
const mediaRef = useRef();
@@ -73,3 +75,61 @@ export function useMediaStream(stream, audioOutputDevice, mute = false) {
return mediaRef;
}
+
+export const useSpatialMediaStream = (
+ stream,
+ audioOutputDevice,
+ audioContext,
+ mute = false
+) => {
+ const tileRef = useRef();
+ const [spatialAudio] = useSpatialAudio();
+ // If spatial audio is enabled, we handle mute state separately from the video element
+ const mediaRef = useMediaStream(
+ stream,
+ audioOutputDevice,
+ spatialAudio || mute
+ );
+
+ const pannerNodeRef = useRef();
+ if (!pannerNodeRef.current) {
+ pannerNodeRef.current = new PannerNode(audioContext, {
+ panningModel: "HRTF",
+ });
+ }
+
+ useEffect(() => {
+ if (spatialAudio && tileRef.current && mediaRef.current && !mute) {
+ const tile = tileRef.current;
+ const pannerNode = pannerNodeRef.current;
+
+ const source = audioContext.createMediaElementSource(mediaRef.current);
+ const updatePosition = () => {
+ const bounds = tile.getBoundingClientRect();
+ const windowSize = Math.max(window.innerWidth, window.innerHeight);
+ // Position the source relative to its placement in the window
+ pannerNodeRef.current.positionX.value =
+ (bounds.x + bounds.width / 2) / windowSize - 0.5;
+ pannerNodeRef.current.positionY.value =
+ (bounds.y + bounds.height / 2) / windowSize - 0.5;
+ // Put the source in front of the listener
+ pannerNodeRef.current.positionZ.value = -2;
+ };
+
+ source.connect(pannerNode);
+ pannerNode.connect(audioContext.destination);
+ // HACK: We abuse the CSS transitionrun event to detect when the tile
+ // moves, because useMeasure, IntersectionObserver, etc. all have no
+ // ability to track changes in the CSS transform property
+ tile.addEventListener("transitionrun", updatePosition);
+
+ return () => {
+ tile.removeEventListener("transitionrun", updatePosition);
+ source.disconnect();
+ pannerNode.disconnect();
+ };
+ }
+ }, [spatialAudio, audioContext, mediaRef, mute]);
+
+ return [tileRef, mediaRef];
+};
From da3d038547337f006911a266a2af1f97f49a657f Mon Sep 17 00:00:00 2001
From: Robin Townsend
Date: Tue, 31 May 2022 16:11:39 -0400
Subject: [PATCH 13/25] Make it work on Chrome
---
src/video-grid/useMediaStream.js | 16 ++++++++++++----
1 file changed, 12 insertions(+), 4 deletions(-)
diff --git a/src/video-grid/useMediaStream.js b/src/video-grid/useMediaStream.js
index 0f8c3bf..0ae058b 100644
--- a/src/video-grid/useMediaStream.js
+++ b/src/video-grid/useMediaStream.js
@@ -84,7 +84,7 @@ export const useSpatialMediaStream = (
) => {
const tileRef = useRef();
const [spatialAudio] = useSpatialAudio();
- // If spatial audio is enabled, we handle mute state separately from the video element
+ // If spatial audio is enabled, we handle audio separately from the video element
const mediaRef = useMediaStream(
stream,
audioOutputDevice,
@@ -95,15 +95,22 @@ export const useSpatialMediaStream = (
if (!pannerNodeRef.current) {
pannerNodeRef.current = new PannerNode(audioContext, {
panningModel: "HRTF",
+ refDistance: 3,
});
}
+ const sourceRef = useRef();
+
useEffect(() => {
- if (spatialAudio && tileRef.current && mediaRef.current && !mute) {
+ if (spatialAudio && tileRef.current && !mute) {
+ if (!sourceRef.current) {
+ sourceRef.current = audioContext.createMediaStreamSource(stream);
+ }
+
const tile = tileRef.current;
+ const source = sourceRef.current;
const pannerNode = pannerNodeRef.current;
- const source = audioContext.createMediaElementSource(mediaRef.current);
const updatePosition = () => {
const bounds = tile.getBoundingClientRect();
const windowSize = Math.max(window.innerWidth, window.innerHeight);
@@ -116,6 +123,7 @@ export const useSpatialMediaStream = (
pannerNodeRef.current.positionZ.value = -2;
};
+ updatePosition();
source.connect(pannerNode);
pannerNode.connect(audioContext.destination);
// HACK: We abuse the CSS transitionrun event to detect when the tile
@@ -129,7 +137,7 @@ export const useSpatialMediaStream = (
pannerNode.disconnect();
};
}
- }, [spatialAudio, audioContext, mediaRef, mute]);
+ }, [stream, spatialAudio, audioContext, mute]);
return [tileRef, mediaRef];
};
From e21094b5254192a9e86f29b27fb38dca563575c0 Mon Sep 17 00:00:00 2001
From: Robin Townsend
Date: Tue, 31 May 2022 16:21:35 -0400
Subject: [PATCH 14/25] Fix crash when setting audio output on Chrome for
Android
---
src/video-grid/useMediaStream.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/video-grid/useMediaStream.js b/src/video-grid/useMediaStream.js
index 15efcff..2432a73 100644
--- a/src/video-grid/useMediaStream.js
+++ b/src/video-grid/useMediaStream.js
@@ -55,7 +55,8 @@ export function useMediaStream(stream, audioOutputDevice, mute = false) {
mediaRef.current !== undefined
) {
console.log(`useMediaStream setSinkId ${audioOutputDevice}`);
- mediaRef.current.setSinkId(audioOutputDevice);
+ // Chrome for Android doesn't support this
+ mediaRef.current.setSinkId?.(audioOutputDevice);
}
}, [audioOutputDevice]);
From 2a69b72bedc8804008b29966b35c37113c986e37 Mon Sep 17 00:00:00 2001
From: Robin Townsend
Date: Tue, 31 May 2022 18:01:34 -0400
Subject: [PATCH 15/25] Add a VU meter-style animation to radio mode
---
src/room/PTTButton.module.css | 4 ----
src/room/PTTButton.tsx | 27 +++++++++++++++++++--
src/room/PTTCallView.tsx | 2 ++
src/room/usePTT.ts | 44 ++++++++++++++++++++++++-----------
4 files changed, 57 insertions(+), 20 deletions(-)
diff --git a/src/room/PTTButton.module.css b/src/room/PTTButton.module.css
index 1a8dd17..73ca76d 100644
--- a/src/room/PTTButton.module.css
+++ b/src/room/PTTButton.module.css
@@ -13,13 +13,9 @@
.talking {
background-color: #0dbd8b;
- box-shadow: 0px 0px 0px 17px rgba(13, 189, 139, 0.2),
- 0px 0px 0px 34px rgba(13, 189, 139, 0.2);
}
.error {
background-color: #ff5b55;
border-color: #ff5b55;
- box-shadow: 0px 0px 0px 17px rgba(255, 91, 85, 0.2),
- 0px 0px 0px 34px rgba(255, 91, 85, 0.2);
}
diff --git a/src/room/PTTButton.tsx b/src/room/PTTButton.tsx
index 4da324c..07db646 100644
--- a/src/room/PTTButton.tsx
+++ b/src/room/PTTButton.tsx
@@ -16,6 +16,7 @@ limitations under the License.
import React, { useCallback, useEffect, useState, createRef } from "react";
import classNames from "classnames";
+import { useSpring, animated } from "@react-spring/web";
import styles from "./PTTButton.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
@@ -27,6 +28,7 @@ interface Props {
activeSpeakerDisplayName: string;
activeSpeakerAvatarUrl: string;
activeSpeakerIsLocalUser: boolean;
+ activeSpeakerVolume: number;
size: number;
startTalking: () => void;
stopTalking: () => void;
@@ -44,6 +46,7 @@ export const PTTButton: React.FC = ({
activeSpeakerDisplayName,
activeSpeakerAvatarUrl,
activeSpeakerIsLocalUser,
+ activeSpeakerVolume,
size,
startTalking,
stopTalking,
@@ -130,12 +133,32 @@ export const PTTButton: React.FC = ({
);
};
}, [onWindowMouseUp, onWindowTouchEnd, onButtonTouchStart, buttonRef]);
+
+ const { shadow } = useSpring({
+ shadow: (Math.max(activeSpeakerVolume, -70) + 70) * 0.7,
+ config: {
+ clamp: true,
+ tension: 300,
+ },
+ });
+ const shadowColor = showTalkOverError
+ ? "rgba(255, 91, 85, 0.2)"
+ : "rgba(13, 189, 139, 0.2)";
+
return (
-
+
);
};
diff --git a/src/room/PTTCallView.tsx b/src/room/PTTCallView.tsx
index 4b395f8..5f46d8e 100644
--- a/src/room/PTTCallView.tsx
+++ b/src/room/PTTCallView.tsx
@@ -124,6 +124,7 @@ export const PTTCallView: React.FC = ({
talkOverEnabled,
setTalkOverEnabled,
activeSpeakerUserId,
+ activeSpeakerVolume,
startTalking,
stopTalking,
transmitBlocked,
@@ -223,6 +224,7 @@ export const PTTCallView: React.FC = ({
activeSpeakerDisplayName={activeSpeakerDisplayName}
activeSpeakerAvatarUrl={activeSpeakerAvatarUrl}
activeSpeakerIsLocalUser={activeSpeakerIsLocalUser}
+ activeSpeakerVolume={activeSpeakerVolume}
size={pttButtonSize}
startTalking={startTalking}
stopTalking={stopTalking}
diff --git a/src/room/usePTT.ts b/src/room/usePTT.ts
index 5965c54..02558f9 100644
--- a/src/room/usePTT.ts
+++ b/src/room/usePTT.ts
@@ -49,6 +49,7 @@ export interface PTTState {
talkOverEnabled: boolean;
setTalkOverEnabled: (boolean) => void;
activeSpeakerUserId: string;
+ activeSpeakerVolume: number;
startTalking: () => void;
stopTalking: () => void;
transmitBlocked: boolean;
@@ -87,6 +88,7 @@ export const usePTT = (
isAdmin,
talkOverEnabled,
activeSpeakerUserId,
+ activeSpeakerVolume,
transmitBlocked,
},
setState,
@@ -100,6 +102,7 @@ export const usePTT = (
talkOverEnabled: false,
pttButtonHeld: false,
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
+ activeSpeakerVolume: -Infinity,
transmitBlocked: false,
};
});
@@ -131,15 +134,11 @@ export const usePTT = (
playClip(PTTClipID.BLOCKED);
}
- setState((prevState) => {
- return {
- ...prevState,
- activeSpeakerUserId: activeSpeakerFeed
- ? activeSpeakerFeed.userId
- : null,
- transmitBlocked: blocked,
- };
- });
+ setState((prevState) => ({
+ ...prevState,
+ activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
+ transmitBlocked: blocked,
+ }));
}, [
playClip,
groupCall,
@@ -152,7 +151,7 @@ export const usePTT = (
useEffect(() => {
for (const callFeed of userMediaFeeds) {
- callFeed.addListener(CallFeedEvent.MuteStateChanged, onMuteStateChanged);
+ callFeed.on(CallFeedEvent.MuteStateChanged, onMuteStateChanged);
}
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
@@ -164,14 +163,30 @@ export const usePTT = (
return () => {
for (const callFeed of userMediaFeeds) {
- callFeed.removeListener(
- CallFeedEvent.MuteStateChanged,
- onMuteStateChanged
- );
+ callFeed.off(CallFeedEvent.MuteStateChanged, onMuteStateChanged);
}
};
}, [userMediaFeeds, onMuteStateChanged, groupCall]);
+ const onVolumeChanged = useCallback((volume: number) => {
+ setState((prevState) => ({
+ ...prevState,
+ activeSpeakerVolume: volume,
+ }));
+ }, []);
+
+ useEffect(() => {
+ const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
+ activeSpeakerFeed?.on(CallFeedEvent.VolumeChanged, onVolumeChanged);
+ return () => {
+ activeSpeakerFeed?.off(CallFeedEvent.VolumeChanged, onVolumeChanged);
+ setState((prevState) => ({
+ ...prevState,
+ activeSpeakerVolume: -Infinity,
+ }));
+ };
+ }, [activeSpeakerUserId, onVolumeChanged, userMediaFeeds, groupCall]);
+
const startTalking = useCallback(async () => {
if (pttButtonHeld) return;
@@ -275,6 +290,7 @@ export const usePTT = (
talkOverEnabled,
setTalkOverEnabled,
activeSpeakerUserId,
+ activeSpeakerVolume,
startTalking,
stopTalking,
transmitBlocked,
From 14fbddf78011296097b2111c3f3dd199967e9961 Mon Sep 17 00:00:00 2001
From: Robin Townsend
Date: Tue, 31 May 2022 18:08:42 -0400
Subject: [PATCH 16/25] Make PTTButton feel more clickable
---
src/room/PTTButton.module.css | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/room/PTTButton.module.css b/src/room/PTTButton.module.css
index 1a8dd17..d6b7a76 100644
--- a/src/room/PTTButton.module.css
+++ b/src/room/PTTButton.module.css
@@ -9,12 +9,14 @@
background-color: #21262c;
position: relative;
padding: 0;
+ cursor: pointer;
}
.talking {
background-color: #0dbd8b;
box-shadow: 0px 0px 0px 17px rgba(13, 189, 139, 0.2),
0px 0px 0px 34px rgba(13, 189, 139, 0.2);
+ cursor: unset;
}
.error {
From 641e6c53b6b178a01da8fbc80880edc78b758d02 Mon Sep 17 00:00:00 2001
From: Robin Townsend
Date: Tue, 31 May 2022 23:41:05 -0400
Subject: [PATCH 17/25] Make the animation smaller
---
src/room/PTTButton.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/room/PTTButton.tsx b/src/room/PTTButton.tsx
index 07db646..e2eb2fe 100644
--- a/src/room/PTTButton.tsx
+++ b/src/room/PTTButton.tsx
@@ -135,7 +135,7 @@ export const PTTButton: React.FC = ({
}, [onWindowMouseUp, onWindowTouchEnd, onButtonTouchStart, buttonRef]);
const { shadow } = useSpring({
- shadow: (Math.max(activeSpeakerVolume, -70) + 70) * 0.7,
+ shadow: (Math.max(activeSpeakerVolume, -70) + 70) * 0.5,
config: {
clamp: true,
tension: 300,
From 9edc1acc9000635739070abccc32205ae1cb99f7 Mon Sep 17 00:00:00 2001
From: David Baker
Date: Wed, 1 Jun 2022 09:07:00 +0100
Subject: [PATCH 18/25] Add type to indexeddb variable
---
src/matrix-utils.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/matrix-utils.ts b/src/matrix-utils.ts
index e3617c5..7f98f9f 100644
--- a/src/matrix-utils.ts
+++ b/src/matrix-utils.ts
@@ -48,7 +48,7 @@ export async function initClient(
window.OLM_OPTIONS = {};
await Olm.init({ locateFile: () => olmWasmPath });
- let indexedDB;
+ let indexedDB: IDBFactory;
try {
indexedDB = window.indexedDB;
From 2cf40ff0b813162e741414c70646f3bfacaec896 Mon Sep 17 00:00:00 2001
From: David Baker
Date: Wed, 1 Jun 2022 09:29:47 +0100
Subject: [PATCH 19/25] Fix room creation
The room alias is not part of the spec. Synapse returns it anyway,
but it's not part of the js-sdk types. We don't really need the
server to tell us what the alias is, so just generate it locally
instead.
---
src/home/RegisteredView.jsx | 4 ++--
src/home/UnauthenticatedView.jsx | 4 ++--
src/matrix-utils.ts | 15 +++++++++++----
3 files changed, 15 insertions(+), 8 deletions(-)
diff --git a/src/home/RegisteredView.jsx b/src/home/RegisteredView.jsx
index 0fc1b3e..aaa8a33 100644
--- a/src/home/RegisteredView.jsx
+++ b/src/home/RegisteredView.jsx
@@ -15,7 +15,7 @@ limitations under the License.
*/
import React, { useState, useCallback } from "react";
-import { createRoom, roomAliasFromRoomName } from "../matrix-utils";
+import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useGroupCallRooms } from "./useGroupCallRooms";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import commonStyles from "./common.module.css";
@@ -56,7 +56,7 @@ export function RegisteredView({ client }) {
submit().catch((error) => {
if (error.errcode === "M_ROOM_IN_USE") {
- setExistingRoomId(roomAliasFromRoomName(roomName));
+ setExistingRoomId(roomAliasLocalpartFromRoomName(roomName));
setLoading(false);
setError(undefined);
modalState.open();
diff --git a/src/home/UnauthenticatedView.jsx b/src/home/UnauthenticatedView.jsx
index 51e8eed..aea8ae8 100644
--- a/src/home/UnauthenticatedView.jsx
+++ b/src/home/UnauthenticatedView.jsx
@@ -22,7 +22,7 @@ import { useHistory } from "react-router-dom";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { randomString } from "matrix-js-sdk/src/randomstring";
-import { createRoom, roomAliasFromRoomName } from "../matrix-utils";
+import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
@@ -75,7 +75,7 @@ export function UnauthenticatedView() {
if (error.errcode === "M_ROOM_IN_USE") {
setOnFinished(() => () => {
setClient(client, session);
- const aliasLocalpart = roomAliasFromRoomName(roomName);
+ const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
const [, serverName] = client.getUserId().split(":");
history.push(`/room/#${aliasLocalpart}:${serverName}`);
});
diff --git a/src/matrix-utils.ts b/src/matrix-utils.ts
index 7f98f9f..2f7897b 100644
--- a/src/matrix-utils.ts
+++ b/src/matrix-utils.ts
@@ -108,7 +108,7 @@ export async function initClient(
return client;
}
-export function roomAliasFromRoomName(roomName: string): string {
+export function roomAliasLocalpartFromRoomName(roomName: string): string {
return roomName
.trim()
.replace(/\s/g, "-")
@@ -116,6 +116,13 @@ export function roomAliasFromRoomName(roomName: string): string {
.toLowerCase();
}
+export function fullAliasFromRoomName(
+ roomName: string,
+ client: MatrixClient
+): string {
+ return `#${roomAliasLocalpartFromRoomName(roomName)}:${client.getDomain()}`;
+}
+
export function roomNameFromRoomId(roomId: string): string {
return roomId
.match(/([^:]+):.*$/)[1]
@@ -151,7 +158,7 @@ export async function createRoom(
visibility: Visibility.Private,
preset: Preset.PublicChat,
name,
- room_alias_name: roomAliasFromRoomName(name),
+ room_alias_name: roomAliasLocalpartFromRoomName(name),
power_level_content_override: {
invite: 100,
kick: 100,
@@ -177,7 +184,7 @@ export async function createRoom(
},
});
- console.log({ isPtt });
+ console.log(`Creating ${isPtt ? "PTT" : "video"} group call room`);
await client.createGroupCall(
createRoomResult.room_id,
@@ -186,7 +193,7 @@ export async function createRoom(
GroupCallIntent.Prompt
);
- return createRoomResult.room_id;
+ return fullAliasFromRoomName(name, client);
}
export function getRoomUrl(roomId: string): string {
From 7ee2f630db0e97f4f61c79a2366ae2f75c958fb8 Mon Sep 17 00:00:00 2001
From: David Baker
Date: Wed, 1 Jun 2022 09:59:59 +0100
Subject: [PATCH 20/25] Add more typers to useInteractiveLogin
otherwise apparently Typescript can't trace the MatrixClient type
through.
---
src/auth/useInteractiveLogin.ts | 68 +++++++++++++++++++--------------
1 file changed, 39 insertions(+), 29 deletions(-)
diff --git a/src/auth/useInteractiveLogin.ts b/src/auth/useInteractiveLogin.ts
index b4e3ad2..2cfe78c 100644
--- a/src/auth/useInteractiveLogin.ts
+++ b/src/auth/useInteractiveLogin.ts
@@ -16,40 +16,50 @@ limitations under the License.
import { useCallback } from "react";
import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
+import { MatrixClient } from "matrix-js-sdk";
import { initClient, defaultHomeserver } from "../matrix-utils";
+import { Session } from "../ClientContext";
export const useInteractiveLogin = () =>
- useCallback(
- async (homeserver: string, username: string, password: string) => {
- const authClient = matrix.createClient(homeserver);
+ useCallback<
+ (
+ homeserver: string,
+ username: string,
+ password: string
+ ) => Promise<[MatrixClient, Session]>
+ >(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,
- }),
- });
+ 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 };
+ /* eslint-disable camelcase */
+ const { user_id, access_token, device_id } =
+ await interactiveAuth.attemptAuth();
+ const session = {
+ user_id,
+ access_token,
+ device_id,
+ passwordlessUser: false,
+ };
- const client = await initClient({
- baseUrl: defaultHomeserver,
- accessToken: access_token,
- userId: user_id,
- deviceId: device_id,
- });
- /* eslint-enable camelcase */
+ const client = await initClient({
+ baseUrl: defaultHomeserver,
+ accessToken: access_token,
+ userId: user_id,
+ deviceId: device_id,
+ });
+ /* eslint-enable camelcase */
- return [client, session];
- },
- []
- );
+ return [client, session];
+ }, []);
From 771424cbf0d266d95543bb4bc84a058570d777ac Mon Sep 17 00:00:00 2001
From: David Baker
Date: Wed, 1 Jun 2022 10:11:02 +0100
Subject: [PATCH 21/25] Expand comment
---
src/room/usePTT.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/room/usePTT.ts b/src/room/usePTT.ts
index cc42140..3446ff6 100644
--- a/src/room/usePTT.ts
+++ b/src/room/usePTT.ts
@@ -31,7 +31,11 @@ function getActiveSpeakerFeed(
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
+ // the same one as the active speaker. The custom sort function sorts
+ // by user ID, so needs a collator of some kind to compare. We make a
+ // specific one to help ensure every client sorts the same way
+ // although of course user IDs shouldn't contain accented characters etc.
+ // anyway).
const collator = new Intl.Collator("en", {
sensitivity: "variant",
usage: "sort",
From 64e30c89e393e3a773a1511496b3af99c0caf6cb Mon Sep 17 00:00:00 2001
From: David Baker
Date: Wed, 1 Jun 2022 10:13:20 +0100
Subject: [PATCH 22/25] Comment typo
Co-authored-by: Robin
---
src/room/usePTT.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/room/usePTT.ts b/src/room/usePTT.ts
index 328ea44..1a72faa 100644
--- a/src/room/usePTT.ts
+++ b/src/room/usePTT.ts
@@ -55,7 +55,7 @@ export interface PTTState {
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
+ // it's peer to peer so we can't really say which peer is 'disconnected' if
// there's only one other person in the call and they've lost Internet.
connected: boolean;
}
From d9bd48b9a68de6c866114b3ffb0ec6f8e2a1518e Mon Sep 17 00:00:00 2001
From: David Baker
Date: Wed, 1 Jun 2022 10:21:44 +0100
Subject: [PATCH 23/25] Split out client sync listeber into separate useEffect
---
src/room/usePTT.ts | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/src/room/usePTT.ts b/src/room/usePTT.ts
index 1a72faa..8a1f916 100644
--- a/src/room/usePTT.ts
+++ b/src/room/usePTT.ts
@@ -262,14 +262,10 @@ 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,
@@ -285,6 +281,14 @@ export const usePTT = (
onClientSync,
]);
+ useEffect(() => {
+ client.on(ClientEvent.Sync, onClientSync);
+
+ return () => {
+ client.removeListener(ClientEvent.Sync, onClientSync);
+ };
+ }, [client, onClientSync]);
+
const setTalkOverEnabled = useCallback((talkOverEnabled) => {
setState((prevState) => ({
...prevState,
From 25bde3560b89efd0ce4dacc662a68c6c826033e2 Mon Sep 17 00:00:00 2001
From: Robin Townsend
Date: Wed, 1 Jun 2022 10:41:12 -0400
Subject: [PATCH 24/25] Use color variables
---
src/index.css | 2 ++
src/room/PTTButton.tsx | 4 ++--
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/index.css b/src/index.css
index 25562e3..0272651 100644
--- a/src/index.css
+++ b/src/index.css
@@ -26,6 +26,8 @@ limitations under the License.
--inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f,
U+25c2-2664, U+2666-2763, U+2765-2b05, U+2b07-2b1b, U+2b1d-10FFFF;
--primaryColor: #0dbd8b;
+ --primaryColor-20: #0dbd8b33;
+ --alert-20: #ff5b5533;
--bgColor1: #15191e;
--bgColor2: #21262c;
--bgColor3: #444;
diff --git a/src/room/PTTButton.tsx b/src/room/PTTButton.tsx
index e2eb2fe..62d5d37 100644
--- a/src/room/PTTButton.tsx
+++ b/src/room/PTTButton.tsx
@@ -142,8 +142,8 @@ export const PTTButton: React.FC = ({
},
});
const shadowColor = showTalkOverError
- ? "rgba(255, 91, 85, 0.2)"
- : "rgba(13, 189, 139, 0.2)";
+ ? "var(--alert-20)"
+ : "var(--primaryColor-20)";
return (
Date: Wed, 1 Jun 2022 10:41:49 -0400
Subject: [PATCH 25/25] Bump the animation size up a little bit more
---
src/room/PTTButton.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/room/PTTButton.tsx b/src/room/PTTButton.tsx
index 62d5d37..62299a1 100644
--- a/src/room/PTTButton.tsx
+++ b/src/room/PTTButton.tsx
@@ -135,7 +135,7 @@ export const PTTButton: React.FC = ({
}, [onWindowMouseUp, onWindowTouchEnd, onButtonTouchStart, buttonRef]);
const { shadow } = useSpring({
- shadow: (Math.max(activeSpeakerVolume, -70) + 70) * 0.5,
+ shadow: (Math.max(activeSpeakerVolume, -70) + 70) * 0.6,
config: {
clamp: true,
tension: 300,