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: [
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/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/LoginPage.jsx b/src/auth/LoginPage.tsx
similarity index 86%
rename from src/auth/LoginPage.jsx
rename to src/auth/LoginPage.tsx
index e464172..8ef1ca5 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() {
>
);
-}
+};
diff --git a/src/auth/RegisterPage.jsx b/src/auth/RegisterPage.tsx
similarity index 83%
rename from src/auth/RegisterPage.jsx
rename to src/auth/RegisterPage.tsx
index 0bdd114..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,46 +38,35 @@ 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] =
+ const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
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() {
>
);
-}
+};
diff --git a/src/auth/useInteractiveLogin.js b/src/auth/useInteractiveLogin.ts
similarity index 66%
rename from src/auth/useInteractiveLogin.js
rename to src/auth/useInteractiveLogin.ts
index a9fa804..2cfe78c 100644
--- a/src/auth/useInteractiveLogin.js
+++ b/src/auth/useInteractiveLogin.ts
@@ -14,35 +14,44 @@ 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 { useState, useCallback } from "react";
+import { MatrixClient } from "matrix-js-sdk";
+
import { initClient, defaultHomeserver } from "../matrix-utils";
+import { Session } from "../ClientContext";
-export function useInteractiveLogin() {
- const [state, setState] = useState({ loading: false });
-
- const auth = useCallback(async (homeserver, username, password) => {
+export const useInteractiveLogin = () =>
+ 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,
- busyChanged(loading) {
- setState((prev) => ({ ...prev, loading }));
- },
- async doRequest(_auth, _background) {
- return authClient.login("m.login.password", {
+ 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 session = {
+ user_id,
+ access_token,
+ device_id,
+ passwordlessUser: false,
+ };
const client = await initClient({
baseUrl: defaultHomeserver,
@@ -50,9 +59,7 @@ export function useInteractiveLogin() {
userId: user_id,
deviceId: device_id,
});
+ /* eslint-enable camelcase */
return [client, session];
}, []);
-
- return [state, auth];
-}
diff --git a/src/auth/useInteractiveRegistration.js b/src/auth/useInteractiveRegistration.ts
similarity index 58%
rename from src/auth/useInteractiveRegistration.js
rename to src/auth/useInteractiveRegistration.ts
index 8945d0e..2386b53 100644
--- a/src/auth/useInteractiveRegistration.js
+++ b/src/auth/useInteractiveRegistration.ts
@@ -14,56 +14,60 @@ 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, useEffect, useCallback, useRef } from "react";
+import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
+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 = useRef();
+ if (!authClient.current) {
+ authClient.current = 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.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);
});
}, []);
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.current,
+ doRequest: (auth) =>
+ authClient.current.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 +83,7 @@ export function useInteractiveRegistration() {
},
});
+ /* eslint-disable camelcase */
const { user_id, access_token, device_id } =
await interactiveAuth.attemptAuth();
@@ -91,14 +96,19 @@ 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);
@@ -107,5 +117,5 @@ export function useInteractiveRegistration() {
[]
);
- return [state, register];
-}
+ return [privacyPolicyUrl, recaptchaKey, register];
+};
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/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..6af34c5 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";
@@ -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);
@@ -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/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/matrix-utils.ts b/src/matrix-utils.ts
index 3d295e5..bfcf688 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;
@@ -111,7 +111,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, "-")
@@ -119,6 +119,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]
@@ -154,7 +161,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,
@@ -180,7 +187,7 @@ export async function createRoom(
},
});
- console.log({ isPtt });
+ console.log(`Creating ${isPtt ? "PTT" : "video"} group call room`);
await client.createGroupCall(
createRoomResult.room_id,
@@ -189,7 +196,7 @@ export async function createRoom(
GroupCallIntent.Prompt
);
- return createRoomResult.room_id;
+ return fullAliasFromRoomName(name, client);
}
export function getRoomUrl(roomId: string): string {
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/PTTButton.module.css b/src/room/PTTButton.module.css
index 1a8dd17..fb849ad 100644
--- a/src/room/PTTButton.module.css
+++ b/src/room/PTTButton.module.css
@@ -9,17 +9,15 @@
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 {
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..62299a1 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.6,
+ config: {
+ clamp: true,
+ tension: 300,
+ },
+ });
+ const shadowColor = showTalkOverError
+ ? "var(--alert-20)"
+ : "var(--primaryColor-20)";
+
return (
-
+
);
};
diff --git a/src/room/PTTCallView.tsx b/src/room/PTTCallView.tsx
index 4b395f8..5e3f304 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) {
@@ -84,8 +87,6 @@ interface Props {
participants: RoomMember[];
userMediaFeeds: CallFeed[];
onLeave: () => void;
- setShowInspector: (boolean) => void;
- showInspector: boolean;
}
export const PTTCallView: React.FC = ({
@@ -97,8 +98,6 @@ export const PTTCallView: React.FC = ({
participants,
userMediaFeeds,
onLeave,
- setShowInspector,
- showInspector,
}) => {
const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState();
@@ -124,9 +123,11 @@ export const PTTCallView: React.FC = ({
talkOverEnabled,
setTalkOverEnabled,
activeSpeakerUserId,
+ activeSpeakerVolume,
startTalking,
stopTalking,
transmitBlocked,
+ connected,
} = usePTT(
client,
groupCall,
@@ -189,8 +190,6 @@ export const PTTCallView: React.FC = ({
= ({
activeSpeakerDisplayName={activeSpeakerDisplayName}
activeSpeakerAvatarUrl={activeSpeakerAvatarUrl}
activeSpeakerIsLocalUser={activeSpeakerIsLocalUser}
+ activeSpeakerVolume={activeSpeakerVolume}
size={pttButtonSize}
startTalking={startTalking}
stopTalking={stopTalking}
@@ -234,7 +234,8 @@ export const PTTCallView: React.FC = ({
activeSpeakerIsLocalUser,
talkOverEnabled,
activeSpeakerUserId,
- activeSpeakerDisplayName
+ activeSpeakerDisplayName,
+ connected
)}
{userMediaFeeds.map((callFeed) => (
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);
diff --git a/src/room/VideoPreview.jsx b/src/room/VideoPreview.jsx
index d0e1774..996e4ae 100644
--- a/src/room/VideoPreview.jsx
+++ b/src/room/VideoPreview.jsx
@@ -35,8 +35,6 @@ export function VideoPreview({
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
- setShowInspector,
- showInspector,
audioOutput,
stream,
}) {
@@ -83,8 +81,6 @@ export function VideoPreview({
/>
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[];
+ 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): UseGroupCallType {
const [
{
state,
@@ -41,20 +88,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 +127,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 +156,10 @@ export function useGroupCall(groupCall) {
}
function onLocalScreenshareStateChanged(
- isScreensharing,
- localScreenshareFeed,
- localDesktopCapturerSourceId
- ) {
+ isScreensharing: boolean,
+ localScreenshareFeed: CallFeed,
+ localDesktopCapturerSourceId: string
+ ): void {
updateState({
isScreensharing,
localScreenshareFeed,
@@ -112,13 +167,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(),
diff --git a/src/room/usePTT.ts b/src/room/usePTT.ts
index 5965c54..7710655 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";
@@ -30,6 +31,21 @@ 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. 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",
+ 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) {
@@ -49,9 +65,15 @@ export interface PTTState {
talkOverEnabled: boolean;
setTalkOverEnabled: (boolean) => void;
activeSpeakerUserId: string;
+ activeSpeakerVolume: number;
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 which peer is 'disconnected' if
+ // there's only one other person in the call and they've lost Internet.
+ connected: boolean;
}
export const usePTT = (
@@ -87,6 +109,7 @@ export const usePTT = (
isAdmin,
talkOverEnabled,
activeSpeakerUserId,
+ activeSpeakerVolume,
transmitBlocked,
},
setState,
@@ -100,6 +123,7 @@ export const usePTT = (
talkOverEnabled: false,
pttButtonHeld: false,
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
+ activeSpeakerVolume: -Infinity,
transmitBlocked: false,
};
});
@@ -131,15 +155,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 +172,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 +184,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;
@@ -211,6 +247,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") {
@@ -260,8 +307,18 @@ export const usePTT = (
pttButtonHeld,
enablePTTButton,
setMicMuteWrapper,
+ client,
+ onClientSync,
]);
+ useEffect(() => {
+ client.on(ClientEvent.Sync, onClientSync);
+
+ return () => {
+ client.removeListener(ClientEvent.Sync, onClientSync);
+ };
+ }, [client, onClientSync]);
+
const setTalkOverEnabled = useCallback((talkOverEnabled) => {
setState((prevState) => ({
...prevState,
@@ -275,8 +332,10 @@ export const usePTT = (
talkOverEnabled,
setTalkOverEnabled,
activeSpeakerUserId,
+ activeSpeakerVolume,
startTalking,
stopTalking,
transmitBlocked,
+ connected,
};
};
diff --git a/src/settings/SettingsModal.jsx b/src/settings/SettingsModal.jsx
index 3868864..60665c6 100644
--- a/src/settings/SettingsModal.jsx
+++ b/src/settings/SettingsModal.jsx
@@ -24,12 +24,13 @@ import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
import { SelectInput } from "../input/SelectInput";
import { Item } from "@react-stately/collections";
import { useMediaHandler } from "./useMediaHandler";
+import { useSpatialAudio, useShowInspector } from "./useSetting";
import { FieldRow, InputField } from "../input/Input";
import { Button } from "../button";
import { useDownloadDebugLog } from "./submit-rageshake";
import { Body } from "../typography/Typography";
-export function SettingsModal({ setShowInspector, showInspector, ...rest }) {
+export const SettingsModal = (props) => {
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..774c6dd 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();
@@ -55,7 +57,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]);
@@ -73,3 +76,69 @@ 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 audio 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",
+ refDistance: 3,
+ });
+ }
+
+ const sourceRef = useRef();
+
+ useEffect(() => {
+ 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 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;
+ };
+
+ updatePosition();
+ 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();
+ };
+ }
+ }, [stream, spatialAudio, audioContext, mute]);
+
+ return [tileRef, mediaRef];
+};
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"