Merge remote-tracking branch 'origin/main' into dbkr/lower_sdk_timeout

This commit is contained in:
David Baker 2022-06-01 16:02:48 +01:00
commit 582e6637dc
30 changed files with 664 additions and 356 deletions

View file

@ -16,9 +16,6 @@ module.exports = {
"sourceType": "module", "sourceType": "module",
}, },
rules: { 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"], "jsx-a11y/media-has-caption": ["off"],
}, },
overrides: [ overrides: [

View file

@ -8,7 +8,7 @@
"build-storybook": "build-storybook", "build-storybook": "build-storybook",
"prettier:check": "prettier -c src", "prettier:check": "prettier -c src",
"prettier:format": "prettier -w src", "prettier:format": "prettier -w src",
"lint:js": "eslint --max-warnings 2 src", "lint:js": "eslint --max-warnings 0 src",
"lint:types": "tsc" "lint:types": "tsc"
}, },
"dependencies": { "dependencies": {
@ -31,6 +31,7 @@
"@react-stately/tree": "^3.2.0", "@react-stately/tree": "^3.2.0",
"@sentry/react": "^6.13.3", "@sentry/react": "^6.13.3",
"@sentry/tracing": "^6.13.3", "@sentry/tracing": "^6.13.3",
"@types/grecaptcha": "^3.0.4",
"@use-gesture/react": "^10.2.11", "@use-gesture/react": "^10.2.11",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"color-hash": "^2.0.1", "color-hash": "^2.0.1",

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React, { import React, {
FC,
useCallback, useCallback,
useEffect, useEffect,
useState, useState,
@ -23,17 +24,59 @@ import React, {
useContext, useContext,
} from "react"; } from "react";
import { useHistory } from "react-router-dom"; 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 { ErrorView } from "./FullScreenView";
import { initClient, defaultHomeserver } from "./matrix-utils"; 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<void>;
logout: () => void;
setClient: (client: MatrixClient, session: Session) => void;
}
const ClientContext = createContext<ClientState>(null);
type ClientProviderState = Omit<
ClientState,
"changePassword" | "logout" | "setClient"
> & { error?: Error };
export const ClientProvider: FC = ({ children }) => {
const history = useHistory(); const history = useHistory();
const [ const [
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error }, { loading, isAuthenticated, isPasswordlessUser, client, userName, error },
setState, setState,
] = useState({ ] = useState<ClientProviderState>({
loading: true, loading: true,
isAuthenticated: false, isAuthenticated: false,
isPasswordlessUser: false, isPasswordlessUser: false,
@ -43,18 +86,16 @@ export function ClientProvider({ children }) {
}); });
useEffect(() => { useEffect(() => {
async function restore() { const restore = async (): Promise<
Pick<ClientProviderState, "client" | "isPasswordlessUser">
> => {
try { try {
const authStore = localStorage.getItem("matrix-auth-store"); const session = loadSession();
if (authStore) { if (session) {
const { /* eslint-disable camelcase */
user_id, const { user_id, device_id, access_token, passwordlessUser } =
device_id, session;
access_token,
passwordlessUser,
tempPassword,
} = JSON.parse(authStore);
const client = await initClient({ const client = await initClient({
baseUrl: defaultHomeserver, baseUrl: defaultHomeserver,
@ -62,37 +103,26 @@ export function ClientProvider({ children }) {
userId: user_id, userId: user_id,
deviceId: device_id, deviceId: device_id,
}); });
/* eslint-enable camelcase */
localStorage.setItem( return { client, isPasswordlessUser: passwordlessUser };
"matrix-auth-store",
JSON.stringify({
user_id,
device_id,
access_token,
passwordlessUser,
tempPassword,
})
);
return { client, passwordlessUser };
} }
return { client: undefined }; return { client: undefined, isPasswordlessUser: false };
} catch (err) { } catch (err) {
console.error(err); console.error(err);
localStorage.removeItem("matrix-auth-store"); clearSession();
throw err; throw err;
} }
} };
restore() restore()
.then(({ client, passwordlessUser }) => { .then(({ client, isPasswordlessUser }) => {
setState({ setState({
client, client,
loading: false, loading: false,
isAuthenticated: !!client, isAuthenticated: Boolean(client),
isPasswordlessUser: !!passwordlessUser, isPasswordlessUser,
userName: client?.getUserIdLocalpart(), userName: client?.getUserIdLocalpart(),
}); });
}) })
@ -108,31 +138,23 @@ export function ClientProvider({ children }) {
}, []); }, []);
const changePassword = useCallback( const changePassword = useCallback(
async (password) => { async (password: string) => {
const { tempPassword, passwordlessUser, ...existingSession } = JSON.parse( const { tempPassword, ...session } = loadSession();
localStorage.getItem("matrix-auth-store")
);
await client.setPassword( await client.setPassword(
{ {
type: "m.login.password", type: "m.login.password",
identifier: { identifier: {
type: "m.id.user", type: "m.id.user",
user: existingSession.user_id, user: session.user_id,
}, },
user: existingSession.user_id, user: session.user_id,
password: tempPassword, password: tempPassword,
}, },
password password
); );
localStorage.setItem( saveSession({ ...session, passwordlessUser: false });
"matrix-auth-store",
JSON.stringify({
...existingSession,
passwordlessUser: false,
})
);
setState({ setState({
client, client,
@ -146,23 +168,23 @@ export function ClientProvider({ children }) {
); );
const setClient = useCallback( const setClient = useCallback(
(newClient, session) => { (newClient: MatrixClient, session: Session) => {
if (client && client !== newClient) { if (client && client !== newClient) {
client.stopClient(); client.stopClient();
} }
if (newClient) { if (newClient) {
localStorage.setItem("matrix-auth-store", JSON.stringify(session)); saveSession(session);
setState({ setState({
client: newClient, client: newClient,
loading: false, loading: false,
isAuthenticated: true, isAuthenticated: true,
isPasswordlessUser: !!session.passwordlessUser, isPasswordlessUser: session.passwordlessUser,
userName: newClient.getUserIdLocalpart(), userName: newClient.getUserIdLocalpart(),
}); });
} else { } else {
localStorage.removeItem("matrix-auth-store"); clearSession();
setState({ setState({
client: undefined, client: undefined,
@ -177,29 +199,23 @@ export function ClientProvider({ children }) {
); );
const logout = useCallback(() => { const logout = useCallback(() => {
localStorage.removeItem("matrix-auth-store"); clearSession();
window.location = "/"; history.push("/");
}, [history]); }, [history]);
useEffect(() => { useEffect(() => {
if (client) { if (client) {
const loadTime = Date.now(); const loadTime = Date.now();
const onToDeviceEvent = (event) => { const onToDeviceEvent = (event: MatrixEvent) => {
if (event.getType() !== "org.matrix.call_duplicate_session") { if (event.getType() !== "org.matrix.call_duplicate_session") return;
return;
}
const content = event.getContent(); const content = event.getContent();
if (content.session_id === client.getSessionId()) { if (content.session_id === client.getSessionId()) return;
return;
}
if (content.timestamp > loadTime) { if (content.timestamp > loadTime) {
if (client) { client?.stopClient();
client.stopClient();
}
setState((prev) => ({ setState((prev) => ({
...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.sendToDevice("org.matrix.call_duplicate_session", {
[client.getUserId()]: { [client.getUserId()]: {
@ -219,12 +235,12 @@ export function ClientProvider({ children }) {
}); });
return () => { return () => {
client.removeListener("toDeviceEvent", onToDeviceEvent); client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent);
}; };
} }
}, [client]); }, [client]);
const context = useMemo( const context = useMemo<ClientState>(
() => ({ () => ({
loading, loading,
isAuthenticated, isAuthenticated,
@ -258,8 +274,6 @@ export function ClientProvider({ children }) {
return ( return (
<ClientContext.Provider value={context}>{children}</ClientContext.Provider> <ClientContext.Provider value={context}>{children}</ClientContext.Provider>
); );
} };
export function useClient() { export const useClient = () => useContext(ClientContext);
return useContext(ClientContext);
}

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. 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 { useHistory, useLocation, Link } from "react-router-dom";
import { ReactComponent as Logo } from "../icons/LogoLarge.svg"; import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
import { useClient } from "../ClientContext"; import { useClient } from "../ClientContext";
import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
@ -25,23 +33,23 @@ import styles from "./LoginPage.module.css";
import { useInteractiveLogin } from "./useInteractiveLogin"; import { useInteractiveLogin } from "./useInteractiveLogin";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
export function LoginPage() { export const LoginPage: FC = () => {
usePageTitle("Login"); usePageTitle("Login");
const { setClient } = useClient(); const { setClient } = useClient();
const [_, login] = useInteractiveLogin(); const login = useInteractiveLogin();
const [homeserver, setHomeServer] = useState(defaultHomeserver); const homeserver = defaultHomeserver; // TODO: Make this configurable
const usernameRef = useRef(); const usernameRef = useRef<HTMLInputElement>();
const passwordRef = useRef(); const passwordRef = useRef<HTMLInputElement>();
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState<Error>();
// TODO: Handle hitting login page with authenticated client // TODO: Handle hitting login page with authenticated client
const onSubmitLoginForm = useCallback( const onSubmitLoginForm = useCallback(
(e) => { (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
@ -60,13 +68,13 @@ export function LoginPage() {
setLoading(false); setLoading(false);
}); });
}, },
[login, location, history, homeserver] [login, location, history, homeserver, setClient]
); );
const homeserverHost = useMemo(() => { const homeserverHost = useMemo(() => {
try { try {
return new URL(homeserver).host; return new URL(homeserver).host;
} catch (_error) { } catch (error) {
return defaultHomeserverHost; return defaultHomeserverHost;
} }
}, [homeserver]); }, [homeserver]);
@ -125,4 +133,4 @@ export function LoginPage() {
</div> </div>
</> </>
); );
} };

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. 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 { useHistory, useLocation } from "react-router-dom";
import { captureException } from "@sentry/react"; import { captureException } from "@sentry/react";
import { sleep } from "matrix-js-sdk/src/utils"; import { sleep } from "matrix-js-sdk/src/utils";
import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button"; import { Button } from "../button";
import { useClient } from "../ClientContext"; import { useClient } from "../ClientContext";
@ -30,46 +38,35 @@ import { useRecaptcha } from "./useRecaptcha";
import { Caption, Link } from "../typography/Typography"; import { Caption, Link } from "../typography/Typography";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
export function RegisterPage() { export const RegisterPage: FC = () => {
usePageTitle("Register"); usePageTitle("Register");
const { loading, isAuthenticated, isPasswordlessUser, client, setClient } = const { loading, isAuthenticated, isPasswordlessUser, client, setClient } =
useClient(); useClient();
const confirmPasswordRef = useRef(); const confirmPasswordRef = useRef<HTMLInputElement>();
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
const [registering, setRegistering] = useState(false); const [registering, setRegistering] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState<Error>();
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState(""); const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [{ privacyPolicyUrl, recaptchaKey }, register] = const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration(); useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const onSubmitRegisterForm = useCallback( const onSubmitRegisterForm = useCallback(
(e) => { (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const data = new FormData(e.target); const data = new FormData(e.target as HTMLFormElement);
const userName = data.get("userName"); const userName = data.get("userName") as string;
const password = data.get("password"); const password = data.get("password") as string;
const passwordConfirmation = data.get("passwordConfirmation"); const passwordConfirmation = data.get("passwordConfirmation") as string;
if (password !== passwordConfirmation) { if (password !== passwordConfirmation) return;
return;
}
async function submit() { const submit = async () => {
setRegistering(true); 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 recaptchaResponse = await execute();
const [newClient, session] = await register( const [newClient, session] = await register(
userName, userName,
@ -78,8 +75,11 @@ export function RegisterPage() {
recaptchaResponse recaptchaResponse
); );
if (roomIds) { if (client && isPasswordlessUser) {
for (const roomId of roomIds) { // Migrate the user's rooms
for (const groupCall of client.groupCallEventHandler.groupCalls.values()) {
const roomId = groupCall.room.roomId;
try { try {
await newClient.joinRoom(roomId); await newClient.joinRoom(roomId);
} catch (error) { } catch (error) {
@ -95,11 +95,11 @@ export function RegisterPage() {
} }
setClient(newClient, session); setClient(newClient, session);
} };
submit() submit()
.then(() => { .then(() => {
if (location.state && location.state.from) { if (location.state?.from) {
history.push(location.state.from); history.push(location.state.from);
} else { } else {
history.push("/"); history.push("/");
@ -111,18 +111,23 @@ export function RegisterPage() {
reset(); reset();
}); });
}, },
[register, location, history, isPasswordlessUser, reset, execute, client] [
register,
location,
history,
isPasswordlessUser,
reset,
execute,
client,
setClient,
]
); );
useEffect(() => { useEffect(() => {
if (!confirmPasswordRef.current) {
return;
}
if (password && passwordConfirmation && password !== passwordConfirmation) { if (password && passwordConfirmation && password !== passwordConfirmation) {
confirmPasswordRef.current.setCustomValidity("Passwords must match"); confirmPasswordRef.current?.setCustomValidity("Passwords must match");
} else { } else {
confirmPasswordRef.current.setCustomValidity(""); confirmPasswordRef.current?.setCustomValidity("");
} }
}, [password, passwordConfirmation]); }, [password, passwordConfirmation]);
@ -130,7 +135,7 @@ export function RegisterPage() {
if (!loading && isAuthenticated && !isPasswordlessUser && !registering) { if (!loading && isAuthenticated && !isPasswordlessUser && !registering) {
history.push("/"); history.push("/");
} }
}, [history, isAuthenticated, isPasswordlessUser, registering]); }, [loading, history, isAuthenticated, isPasswordlessUser, registering]);
if (loading) { if (loading) {
return <LoadingView />; return <LoadingView />;
@ -218,4 +223,4 @@ export function RegisterPage() {
</div> </div>
</> </>
); );
} };

View file

@ -14,35 +14,44 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { useCallback } from "react";
import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index"; 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 { initClient, defaultHomeserver } from "../matrix-utils";
import { Session } from "../ClientContext";
export function useInteractiveLogin() { export const useInteractiveLogin = () =>
const [state, setState] = useState({ loading: false }); useCallback<
(
const auth = useCallback(async (homeserver, username, password) => { homeserver: string,
username: string,
password: string
) => Promise<[MatrixClient, Session]>
>(async (homeserver: string, username: string, password: string) => {
const authClient = matrix.createClient(homeserver); const authClient = matrix.createClient(homeserver);
const interactiveAuth = new InteractiveAuth({ const interactiveAuth = new InteractiveAuth({
matrixClient: authClient, matrixClient: authClient,
busyChanged(loading) { doRequest: () =>
setState((prev) => ({ ...prev, loading })); authClient.login("m.login.password", {
},
async doRequest(_auth, _background) {
return authClient.login("m.login.password", {
identifier: { identifier: {
type: "m.id.user", type: "m.id.user",
user: username, user: username,
}, },
password, password,
}); }),
},
}); });
/* eslint-disable camelcase */
const { user_id, access_token, device_id } = const { user_id, access_token, device_id } =
await interactiveAuth.attemptAuth(); 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({ const client = await initClient({
baseUrl: defaultHomeserver, baseUrl: defaultHomeserver,
@ -50,9 +59,7 @@ export function useInteractiveLogin() {
userId: user_id, userId: user_id,
deviceId: device_id, deviceId: device_id,
}); });
/* eslint-enable camelcase */
return [client, session]; return [client, session];
}, []); }, []);
return [state, auth];
}

View file

@ -14,56 +14,60 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
import { useState, useEffect, useCallback, useRef } 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";
import { initClient, defaultHomeserver } from "../matrix-utils"; import { initClient, defaultHomeserver } from "../matrix-utils";
import { Session } from "../ClientContext";
export function useInteractiveRegistration() { export const useInteractiveRegistration = (): [
const [state, setState] = useState({ string,
privacyPolicyUrl: null, string,
loading: false, (
}); username: string,
password: string,
displayName: string,
recaptchaResponse: string,
passwordlessUser?: boolean
) => Promise<[MatrixClient, Session]>
] => {
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string>();
const [recaptchaKey, setRecaptchaKey] = useState<string>();
const authClientRef = useRef(); const authClient = useRef<MatrixClient>();
if (!authClient.current) {
authClient.current = matrix.createClient(defaultHomeserver);
}
useEffect(() => { useEffect(() => {
authClientRef.current = matrix.createClient(defaultHomeserver); authClient.current.registerRequest({}).catch((error) => {
setPrivacyPolicyUrl(
authClientRef.current.registerRequest({}).catch((error) => { error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url
const privacyPolicyUrl = );
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url; setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
const recaptchaKey = error.data?.params["m.login.recaptcha"]?.public_key;
if (privacyPolicyUrl || recaptchaKey) {
setState((prev) => ({ ...prev, privacyPolicyUrl, recaptchaKey }));
}
}); });
}, []); }, []);
const register = useCallback( const register = useCallback(
async ( async (
username, username: string,
password, password: string,
displayName, displayName: string,
recaptchaResponse, recaptchaResponse: string,
passwordlessUser passwordlessUser?: boolean
) => { ): Promise<[MatrixClient, Session]> => {
const interactiveAuth = new InteractiveAuth({ const interactiveAuth = new InteractiveAuth({
matrixClient: authClientRef.current, matrixClient: authClient.current,
busyChanged(loading) { doRequest: (auth) =>
setState((prev) => ({ ...prev, loading })); authClient.current.registerRequest({
},
async doRequest(auth, _background) {
return authClientRef.current.registerRequest({
username, username,
password, password,
auth: auth || undefined, auth: auth || undefined,
}); }),
}, stateUpdated: (nextStage, status) => {
stateUpdated(nextStage, status) {
if (status.error) { if (status.error) {
throw new Error(error); throw new Error(status.error);
} }
if (nextStage === "m.login.terms") { if (nextStage === "m.login.terms") {
@ -79,6 +83,7 @@ export function useInteractiveRegistration() {
}, },
}); });
/* eslint-disable camelcase */
const { user_id, access_token, device_id } = const { user_id, access_token, device_id } =
await interactiveAuth.attemptAuth(); await interactiveAuth.attemptAuth();
@ -91,14 +96,19 @@ export function useInteractiveRegistration() {
await client.setDisplayName(displayName); 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) { if (passwordlessUser) {
session.tempPassword = password; session.tempPassword = password;
} }
const user = client.getUser(client.getUserId()); const user = client.getUser(client.getUserId());
user.setRawDisplayName(displayName); user.setRawDisplayName(displayName);
user.setDisplayName(displayName); user.setDisplayName(displayName);
@ -107,5 +117,5 @@ export function useInteractiveRegistration() {
[] []
); );
return [state, register]; return [privacyPolicyUrl, recaptchaKey, register];
} };

View file

@ -14,52 +14,49 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useEffect, useCallback, useRef, useState } from "react"; 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 = const RECAPTCHA_SCRIPT_URL =
"https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit"; "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 [recaptchaId] = useState(() => randomString(16));
const promiseRef = useRef(); const promiseRef = useRef<RecaptchaPromiseRef>();
useEffect(() => { useEffect(() => {
if (!sitekey) { if (!sitekey) return;
return;
}
const onRecaptchaLoaded = () => { const onRecaptchaLoaded = () => {
if (!document.getElementById(recaptchaId)) { if (!document.getElementById(recaptchaId)) return;
return;
}
window.grecaptcha.render(recaptchaId, { window.grecaptcha.render(recaptchaId, {
sitekey, sitekey,
size: "invisible", size: "invisible",
callback: (response) => { callback: (response: string) => promiseRef.current?.resolve(response),
if (promiseRef.current) { // eslint-disable-next-line @typescript-eslint/naming-convention
promiseRef.current.resolve(response); "error-callback": () => promiseRef.current?.reject(new Error()),
}
},
"error-callback": (error) => {
if (promiseRef.current) {
promiseRef.current.reject(error);
}
},
}); });
}; };
if ( if (typeof window.grecaptcha?.render === "function") {
typeof window.grecaptcha !== "undefined" &&
typeof window.grecaptcha.render === "function"
) {
onRecaptchaLoaded(); onRecaptchaLoaded();
} else { } else {
window.mxOnRecaptchaLoaded = onRecaptchaLoaded; window.mxOnRecaptchaLoaded = onRecaptchaLoaded;
if (!document.querySelector(`script[src="${RECAPTCHA_SCRIPT_URL}"]`)) { 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.src = RECAPTCHA_SCRIPT_URL;
scriptTag.async = true; scriptTag.async = true;
document.body.appendChild(scriptTag); document.body.appendChild(scriptTag);
@ -80,7 +77,7 @@ export function useRecaptcha(sitekey) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const observer = new MutationObserver((mutationsList) => { const observer = new MutationObserver((mutationsList) => {
for (const item of 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")); reject(new Error("Recaptcha dismissed"));
observer.disconnect(); observer.disconnect();
return; return;
@ -101,7 +98,7 @@ export function useRecaptcha(sitekey) {
window.grecaptcha.execute(); window.grecaptcha.execute();
const iframe = document.querySelector( const iframe = document.querySelector<HTMLIFrameElement>(
'iframe[src*="recaptcha/api2/bframe"]' 'iframe[src*="recaptcha/api2/bframe"]'
); );
@ -111,13 +108,11 @@ export function useRecaptcha(sitekey) {
}); });
} }
}); });
}, [recaptchaId, sitekey]); }, [sitekey]);
const reset = useCallback(() => { const reset = useCallback(() => {
if (window.grecaptcha) { window.grecaptcha?.reset();
window.grecaptcha.reset(); }, []);
}
}, [recaptchaId]);
return { execute, reset, recaptchaId }; return { execute, reset, recaptchaId };
} };

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import React, { useState, useCallback } from "react"; import React, { useState, useCallback } from "react";
import { createRoom, roomAliasFromRoomName } from "../matrix-utils"; import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useGroupCallRooms } from "./useGroupCallRooms"; import { useGroupCallRooms } from "./useGroupCallRooms";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import commonStyles from "./common.module.css"; import commonStyles from "./common.module.css";
@ -56,7 +56,7 @@ export function RegisteredView({ client }) {
submit().catch((error) => { submit().catch((error) => {
if (error.errcode === "M_ROOM_IN_USE") { if (error.errcode === "M_ROOM_IN_USE") {
setExistingRoomId(roomAliasFromRoomName(roomName)); setExistingRoomId(roomAliasLocalpartFromRoomName(roomName));
setLoading(false); setLoading(false);
setError(undefined); setError(undefined);
modalState.open(); modalState.open();

View file

@ -22,7 +22,7 @@ import { useHistory } from "react-router-dom";
import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button"; import { Button } from "../button";
import { randomString } from "matrix-js-sdk/src/randomstring"; 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 { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { useModalTriggerState } from "../Modal"; import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal"; import { JoinExistingCallModal } from "./JoinExistingCallModal";
@ -39,7 +39,7 @@ export function UnauthenticatedView() {
const [callType, setCallType] = useState(CallType.Video); const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState();
const [{ privacyPolicyUrl, recaptchaKey }, register] = const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration(); useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
@ -75,7 +75,7 @@ export function UnauthenticatedView() {
if (error.errcode === "M_ROOM_IN_USE") { if (error.errcode === "M_ROOM_IN_USE") {
setOnFinished(() => () => { setOnFinished(() => () => {
setClient(client, session); setClient(client, session);
const aliasLocalpart = roomAliasFromRoomName(roomName); const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
const [, serverName] = client.getUserId().split(":"); const [, serverName] = client.getUserId().split(":");
history.push(`/room/#${aliasLocalpart}:${serverName}`); history.push(`/room/#${aliasLocalpart}:${serverName}`);
}); });

View file

@ -26,6 +26,8 @@ limitations under the License.
--inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, --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; U+25c2-2664, U+2666-2763, U+2765-2b05, U+2b07-2b1b, U+2b1d-10FFFF;
--primaryColor: #0dbd8b; --primaryColor: #0dbd8b;
--primaryColor-20: #0dbd8b33;
--alert-20: #ff5b5533;
--bgColor1: #15191e; --bgColor1: #15191e;
--bgColor2: #21262c; --bgColor2: #21262c;
--bgColor3: #444; --bgColor3: #444;

View file

@ -48,7 +48,7 @@ export async function initClient(
window.OLM_OPTIONS = {}; window.OLM_OPTIONS = {};
await Olm.init({ locateFile: () => olmWasmPath }); await Olm.init({ locateFile: () => olmWasmPath });
let indexedDB; let indexedDB: IDBFactory;
try { try {
indexedDB = window.indexedDB; indexedDB = window.indexedDB;
@ -111,7 +111,7 @@ export async function initClient(
return client; return client;
} }
export function roomAliasFromRoomName(roomName: string): string { export function roomAliasLocalpartFromRoomName(roomName: string): string {
return roomName return roomName
.trim() .trim()
.replace(/\s/g, "-") .replace(/\s/g, "-")
@ -119,6 +119,13 @@ export function roomAliasFromRoomName(roomName: string): string {
.toLowerCase(); .toLowerCase();
} }
export function fullAliasFromRoomName(
roomName: string,
client: MatrixClient
): string {
return `#${roomAliasLocalpartFromRoomName(roomName)}:${client.getDomain()}`;
}
export function roomNameFromRoomId(roomId: string): string { export function roomNameFromRoomId(roomId: string): string {
return roomId return roomId
.match(/([^:]+):.*$/)[1] .match(/([^:]+):.*$/)[1]
@ -154,7 +161,7 @@ export async function createRoom(
visibility: Visibility.Private, visibility: Visibility.Private,
preset: Preset.PublicChat, preset: Preset.PublicChat,
name, name,
room_alias_name: roomAliasFromRoomName(name), room_alias_name: roomAliasLocalpartFromRoomName(name),
power_level_content_override: { power_level_content_override: {
invite: 100, invite: 100,
kick: 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( await client.createGroupCall(
createRoomResult.room_id, createRoomResult.room_id,
@ -189,7 +196,7 @@ export async function createRoom(
GroupCallIntent.Prompt GroupCallIntent.Prompt
); );
return createRoomResult.room_id; return fullAliasFromRoomName(name, client);
} }
export function getRoomUrl(roomId: string): string { export function getRoomUrl(roomId: string): string {

View file

@ -33,19 +33,6 @@ export function GroupCallView({
roomId, roomId,
groupCall, 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 { const {
state, state,
error, error,
@ -104,8 +91,6 @@ export function GroupCallView({
participants={participants} participants={participants}
userMediaFeeds={userMediaFeeds} userMediaFeeds={userMediaFeeds}
onLeave={onLeave} onLeave={onLeave}
setShowInspector={onChangeShowInspector}
showInspector={showInspector}
/> />
); );
} else { } else {
@ -126,8 +111,6 @@ export function GroupCallView({
isScreensharing={isScreensharing} isScreensharing={isScreensharing}
localScreenshareFeed={localScreenshareFeed} localScreenshareFeed={localScreenshareFeed}
screenshareFeeds={screenshareFeeds} screenshareFeeds={screenshareFeeds}
setShowInspector={onChangeShowInspector}
showInspector={showInspector}
roomId={roomId} roomId={roomId}
/> />
); );
@ -156,8 +139,6 @@ export function GroupCallView({
localVideoMuted={localVideoMuted} localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted} toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted} toggleMicrophoneMuted={toggleMicrophoneMuted}
setShowInspector={onChangeShowInspector}
showInspector={showInspector}
roomId={roomId} roomId={roomId}
/> />
); );

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo, useRef } from "react";
import styles from "./InCallView.module.css"; import styles from "./InCallView.module.css";
import { import {
HangupButton, HangupButton,
@ -34,6 +34,7 @@ import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal"; import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { usePreventScroll } from "@react-aria/overlays"; import { usePreventScroll } from "@react-aria/overlays";
import { useMediaHandler } from "../settings/useMediaHandler"; import { useMediaHandler } from "../settings/useMediaHandler";
import { useShowInspector } from "../settings/useSetting";
import { useModalTriggerState } from "../Modal"; import { useModalTriggerState } from "../Modal";
const canScreenshare = "getDisplayMedia" in navigator.mediaDevices; const canScreenshare = "getDisplayMedia" in navigator.mediaDevices;
@ -57,14 +58,16 @@ export function InCallView({
toggleScreensharing, toggleScreensharing,
isScreensharing, isScreensharing,
screenshareFeeds, screenshareFeeds,
setShowInspector,
showInspector,
roomId, roomId,
}) { }) {
usePreventScroll(); usePreventScroll();
const [layout, setLayout] = useVideoGridLayout(screenshareFeeds.length > 0); const [layout, setLayout] = useVideoGridLayout(screenshareFeeds.length > 0);
const { audioOutput } = useMediaHandler(); const { audioOutput } = useMediaHandler();
const [showInspector] = useShowInspector();
const audioContext = useRef();
if (!audioContext.current) audioContext.current = new AudioContext();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } = const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState(); useModalTriggerState();
@ -151,6 +154,7 @@ export function InCallView({
getAvatar={renderAvatar} getAvatar={renderAvatar}
showName={items.length > 2 || item.focused} showName={items.length > 2 || item.focused}
audioOutputDevice={audioOutput} audioOutputDevice={audioOutput}
audioContext={audioContext.current}
disableSpeakingIndicator={items.length < 3} disableSpeakingIndicator={items.length < 3}
{...rest} {...rest}
/> />
@ -169,8 +173,6 @@ export function InCallView({
<OverflowMenu <OverflowMenu
inCall inCall
roomId={roomId} roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client} client={client}
groupCall={groupCall} groupCall={groupCall}
showInvite={true} showInvite={true}

View file

@ -41,8 +41,6 @@ export function LobbyView({
localVideoMuted, localVideoMuted,
toggleLocalVideoMuted, toggleLocalVideoMuted,
toggleMicrophoneMuted, toggleMicrophoneMuted,
setShowInspector,
showInspector,
roomId, roomId,
}) { }) {
const { stream } = useCallFeed(localCallFeed); const { stream } = useCallFeed(localCallFeed);
@ -101,8 +99,6 @@ export function LobbyView({
localVideoMuted={localVideoMuted} localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted} toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted} toggleMicrophoneMuted={toggleMicrophoneMuted}
setShowInspector={setShowInspector}
showInspector={showInspector}
stream={stream} stream={stream}
audioOutput={audioOutput} audioOutput={audioOutput}
/> />

View file

@ -31,8 +31,6 @@ import { FeedbackModal } from "./FeedbackModal";
export function OverflowMenu({ export function OverflowMenu({
roomId, roomId,
setShowInspector,
showInspector,
inCall, inCall,
groupCall, groupCall,
showInvite, showInvite,
@ -88,13 +86,7 @@ export function OverflowMenu({
</Menu> </Menu>
)} )}
</PopoverMenuTrigger> </PopoverMenuTrigger>
{settingsModalState.isOpen && ( {settingsModalState.isOpen && <SettingsModal {...settingsModalProps} />}
<SettingsModal
{...settingsModalProps}
setShowInspector={setShowInspector}
showInspector={showInspector}
/>
)}
{inviteModalState.isOpen && ( {inviteModalState.isOpen && (
<InviteModal roomId={roomId} {...inviteModalProps} /> <InviteModal roomId={roomId} {...inviteModalProps} />
)} )}

View file

@ -9,17 +9,15 @@
background-color: #21262c; background-color: #21262c;
position: relative; position: relative;
padding: 0; padding: 0;
cursor: pointer;
} }
.talking { .talking {
background-color: #0dbd8b; background-color: #0dbd8b;
box-shadow: 0px 0px 0px 17px rgba(13, 189, 139, 0.2), cursor: unset;
0px 0px 0px 34px rgba(13, 189, 139, 0.2);
} }
.error { .error {
background-color: #ff5b55; background-color: #ff5b55;
border-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);
} }

View file

@ -16,6 +16,7 @@ limitations under the License.
import React, { useCallback, useEffect, useState, createRef } from "react"; import React, { useCallback, useEffect, useState, createRef } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { useSpring, animated } from "@react-spring/web";
import styles from "./PTTButton.module.css"; import styles from "./PTTButton.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg"; import { ReactComponent as MicIcon } from "../icons/Mic.svg";
@ -27,6 +28,7 @@ interface Props {
activeSpeakerDisplayName: string; activeSpeakerDisplayName: string;
activeSpeakerAvatarUrl: string; activeSpeakerAvatarUrl: string;
activeSpeakerIsLocalUser: boolean; activeSpeakerIsLocalUser: boolean;
activeSpeakerVolume: number;
size: number; size: number;
startTalking: () => void; startTalking: () => void;
stopTalking: () => void; stopTalking: () => void;
@ -44,6 +46,7 @@ export const PTTButton: React.FC<Props> = ({
activeSpeakerDisplayName, activeSpeakerDisplayName,
activeSpeakerAvatarUrl, activeSpeakerAvatarUrl,
activeSpeakerIsLocalUser, activeSpeakerIsLocalUser,
activeSpeakerVolume,
size, size,
startTalking, startTalking,
stopTalking, stopTalking,
@ -130,12 +133,32 @@ export const PTTButton: React.FC<Props> = ({
); );
}; };
}, [onWindowMouseUp, onWindowTouchEnd, onButtonTouchStart, buttonRef]); }, [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 ( return (
<button <animated.button
className={classNames(styles.pttButton, { className={classNames(styles.pttButton, {
[styles.talking]: activeSpeakerUserId, [styles.talking]: activeSpeakerUserId,
[styles.error]: showTalkOverError, [styles.error]: showTalkOverError,
})} })}
style={{
boxShadow: shadow.to(
(s) =>
`0px 0px 0px ${s}px ${shadowColor}, 0px 0px 0px ${
2 * s
}px ${shadowColor}`
),
}}
onMouseDown={onButtonMouseDown} onMouseDown={onButtonMouseDown}
ref={buttonRef} ref={buttonRef}
> >
@ -154,6 +177,6 @@ export const PTTButton: React.FC<Props> = ({
className={styles.avatar} className={styles.avatar}
/> />
)} )}
</button> </animated.button>
); );
}; };

View file

@ -44,8 +44,11 @@ function getPromptText(
activeSpeakerIsLocalUser: boolean, activeSpeakerIsLocalUser: boolean,
talkOverEnabled: boolean, talkOverEnabled: boolean,
activeSpeakerUserId: string, activeSpeakerUserId: string,
activeSpeakerDisplayName: string activeSpeakerDisplayName: string,
connected: boolean
): string { ): string {
if (!connected) return "Connection Lost";
const isTouchScreen = Boolean(window.ontouchstart !== undefined); const isTouchScreen = Boolean(window.ontouchstart !== undefined);
if (showTalkOverError) { if (showTalkOverError) {
@ -84,8 +87,6 @@ interface Props {
participants: RoomMember[]; participants: RoomMember[];
userMediaFeeds: CallFeed[]; userMediaFeeds: CallFeed[];
onLeave: () => void; onLeave: () => void;
setShowInspector: (boolean) => void;
showInspector: boolean;
} }
export const PTTCallView: React.FC<Props> = ({ export const PTTCallView: React.FC<Props> = ({
@ -97,8 +98,6 @@ export const PTTCallView: React.FC<Props> = ({
participants, participants,
userMediaFeeds, userMediaFeeds,
onLeave, onLeave,
setShowInspector,
showInspector,
}) => { }) => {
const { modalState: inviteModalState, modalProps: inviteModalProps } = const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState(); useModalTriggerState();
@ -124,9 +123,11 @@ export const PTTCallView: React.FC<Props> = ({
talkOverEnabled, talkOverEnabled,
setTalkOverEnabled, setTalkOverEnabled,
activeSpeakerUserId, activeSpeakerUserId,
activeSpeakerVolume,
startTalking, startTalking,
stopTalking, stopTalking,
transmitBlocked, transmitBlocked,
connected,
} = usePTT( } = usePTT(
client, client,
groupCall, groupCall,
@ -189,8 +190,6 @@ export const PTTCallView: React.FC<Props> = ({
<OverflowMenu <OverflowMenu
inCall inCall
roomId={roomId} roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client} client={client}
groupCall={groupCall} groupCall={groupCall}
showInvite={false} showInvite={false}
@ -223,6 +222,7 @@ export const PTTCallView: React.FC<Props> = ({
activeSpeakerDisplayName={activeSpeakerDisplayName} activeSpeakerDisplayName={activeSpeakerDisplayName}
activeSpeakerAvatarUrl={activeSpeakerAvatarUrl} activeSpeakerAvatarUrl={activeSpeakerAvatarUrl}
activeSpeakerIsLocalUser={activeSpeakerIsLocalUser} activeSpeakerIsLocalUser={activeSpeakerIsLocalUser}
activeSpeakerVolume={activeSpeakerVolume}
size={pttButtonSize} size={pttButtonSize}
startTalking={startTalking} startTalking={startTalking}
stopTalking={stopTalking} stopTalking={stopTalking}
@ -234,7 +234,8 @@ export const PTTCallView: React.FC<Props> = ({
activeSpeakerIsLocalUser, activeSpeakerIsLocalUser,
talkOverEnabled, talkOverEnabled,
activeSpeakerUserId, activeSpeakerUserId,
activeSpeakerDisplayName activeSpeakerDisplayName,
connected
)} )}
</p> </p>
{userMediaFeeds.map((callFeed) => ( {userMediaFeeds.map((callFeed) => (

View file

@ -33,7 +33,7 @@ export function RoomAuthView() {
const { setClient } = useClient(); const { setClient } = useClient();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState();
const [{ privacyPolicyUrl, recaptchaKey }, register] = const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration(); useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);

View file

@ -35,8 +35,6 @@ export function VideoPreview({
localVideoMuted, localVideoMuted,
toggleLocalVideoMuted, toggleLocalVideoMuted,
toggleMicrophoneMuted, toggleMicrophoneMuted,
setShowInspector,
showInspector,
audioOutput, audioOutput,
stream, stream,
}) { }) {
@ -83,8 +81,6 @@ export function VideoPreview({
/> />
<OverflowMenu <OverflowMenu
roomId={roomId} roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client} client={client}
feedbackModalState={feedbackModalState} feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps} feedbackModalProps={feedbackModalProps}

View file

@ -18,10 +18,57 @@ import { useCallback, useEffect, useState } from "react";
import { import {
GroupCallEvent, GroupCallEvent,
GroupCallState, GroupCallState,
GroupCall,
} from "matrix-js-sdk/src/webrtc/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"; import { usePageUnload } from "./usePageUnload";
export function useGroupCall(groupCall) { 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[];
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 [ const [
{ {
state, state,
@ -41,20 +88,25 @@ export function useGroupCall(groupCall) {
requestingScreenshare, requestingScreenshare,
}, },
setState, setState,
] = useState({ ] = useState<State>({
state: GroupCallState.LocalCallFeedUninitialized, state: GroupCallState.LocalCallFeedUninitialized,
calls: [], calls: [],
localCallFeed: null,
activeSpeaker: null,
userMediaFeeds: [], userMediaFeeds: [],
error: null,
microphoneMuted: false, microphoneMuted: false,
localVideoMuted: false, localVideoMuted: false,
screenshareFeeds: [],
isScreensharing: false, isScreensharing: false,
screenshareFeeds: [],
localScreenshareFeed: null,
localDesktopCapturerSourceId: null,
requestingScreenshare: false, requestingScreenshare: false,
participants: [], participants: [],
hasLocalParticipant: false, hasLocalParticipant: false,
}); });
const updateState = (state) => const updateState = (state: Partial<State>) =>
setState((prevState) => ({ ...prevState, ...state })); setState((prevState) => ({ ...prevState, ...state }));
useEffect(() => { useEffect(() => {
@ -75,25 +127,28 @@ export function useGroupCall(groupCall) {
}); });
} }
function onUserMediaFeedsChanged(userMediaFeeds) { function onUserMediaFeedsChanged(userMediaFeeds: CallFeed[]): void {
updateState({ updateState({
userMediaFeeds: [...userMediaFeeds], userMediaFeeds: [...userMediaFeeds],
}); });
} }
function onScreenshareFeedsChanged(screenshareFeeds) { function onScreenshareFeedsChanged(screenshareFeeds: CallFeed[]): void {
updateState({ updateState({
screenshareFeeds: [...screenshareFeeds], screenshareFeeds: [...screenshareFeeds],
}); });
} }
function onActiveSpeakerChanged(activeSpeaker) { function onActiveSpeakerChanged(activeSpeaker: string): void {
updateState({ updateState({
activeSpeaker: activeSpeaker, activeSpeaker: activeSpeaker,
}); });
} }
function onLocalMuteStateChanged(microphoneMuted, localVideoMuted) { function onLocalMuteStateChanged(
microphoneMuted: boolean,
localVideoMuted: boolean
): void {
updateState({ updateState({
microphoneMuted, microphoneMuted,
localVideoMuted, localVideoMuted,
@ -101,10 +156,10 @@ export function useGroupCall(groupCall) {
} }
function onLocalScreenshareStateChanged( function onLocalScreenshareStateChanged(
isScreensharing, isScreensharing: boolean,
localScreenshareFeed, localScreenshareFeed: CallFeed,
localDesktopCapturerSourceId localDesktopCapturerSourceId: string
) { ): void {
updateState({ updateState({
isScreensharing, isScreensharing,
localScreenshareFeed, localScreenshareFeed,
@ -112,13 +167,13 @@ export function useGroupCall(groupCall) {
}); });
} }
function onCallsChanged(calls) { function onCallsChanged(calls: MatrixCall[]): void {
updateState({ updateState({
calls: [...calls], calls: [...calls],
}); });
} }
function onParticipantsChanged(participants) { function onParticipantsChanged(participants: RoomMember[]): void {
updateState({ updateState({
participants: [...participants], participants: [...participants],
hasLocalParticipant: groupCall.hasLocalParticipant(), hasLocalParticipant: groupCall.hasLocalParticipant(),

View file

@ -15,10 +15,11 @@ limitations under the License.
*/ */
import { useCallback, useEffect, useState } from "react"; 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 { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed"; import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { SyncState } from "matrix-js-sdk/src/sync";
import { PlayClipFunction, PTTClipID } from "../sound/usePttSounds"; import { PlayClipFunction, PTTClipID } from "../sound/usePttSounds";
@ -30,6 +31,21 @@ function getActiveSpeakerFeed(
): CallFeed | null { ): CallFeed | null {
const activeSpeakerFeeds = feeds.filter((f) => !f.isAudioMuted()); 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 activeSpeakerFeed = null;
let highestPowerLevel = null; let highestPowerLevel = null;
for (const feed of activeSpeakerFeeds) { for (const feed of activeSpeakerFeeds) {
@ -49,9 +65,15 @@ export interface PTTState {
talkOverEnabled: boolean; talkOverEnabled: boolean;
setTalkOverEnabled: (boolean) => void; setTalkOverEnabled: (boolean) => void;
activeSpeakerUserId: string; activeSpeakerUserId: string;
activeSpeakerVolume: number;
startTalking: () => void; startTalking: () => void;
stopTalking: () => void; stopTalking: () => void;
transmitBlocked: boolean; 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 = ( export const usePTT = (
@ -87,6 +109,7 @@ export const usePTT = (
isAdmin, isAdmin,
talkOverEnabled, talkOverEnabled,
activeSpeakerUserId, activeSpeakerUserId,
activeSpeakerVolume,
transmitBlocked, transmitBlocked,
}, },
setState, setState,
@ -100,6 +123,7 @@ export const usePTT = (
talkOverEnabled: false, talkOverEnabled: false,
pttButtonHeld: false, pttButtonHeld: false,
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null, activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
activeSpeakerVolume: -Infinity,
transmitBlocked: false, transmitBlocked: false,
}; };
}); });
@ -131,15 +155,11 @@ export const usePTT = (
playClip(PTTClipID.BLOCKED); playClip(PTTClipID.BLOCKED);
} }
setState((prevState) => { setState((prevState) => ({
return { ...prevState,
...prevState, activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
activeSpeakerUserId: activeSpeakerFeed transmitBlocked: blocked,
? activeSpeakerFeed.userId }));
: null,
transmitBlocked: blocked,
};
});
}, [ }, [
playClip, playClip,
groupCall, groupCall,
@ -152,7 +172,7 @@ export const usePTT = (
useEffect(() => { useEffect(() => {
for (const callFeed of userMediaFeeds) { for (const callFeed of userMediaFeeds) {
callFeed.addListener(CallFeedEvent.MuteStateChanged, onMuteStateChanged); callFeed.on(CallFeedEvent.MuteStateChanged, onMuteStateChanged);
} }
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall); const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
@ -164,14 +184,30 @@ export const usePTT = (
return () => { return () => {
for (const callFeed of userMediaFeeds) { for (const callFeed of userMediaFeeds) {
callFeed.removeListener( callFeed.off(CallFeedEvent.MuteStateChanged, onMuteStateChanged);
CallFeedEvent.MuteStateChanged,
onMuteStateChanged
);
} }
}; };
}, [userMediaFeeds, onMuteStateChanged, groupCall]); }, [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 () => { const startTalking = useCallback(async () => {
if (pttButtonHeld) return; if (pttButtonHeld) return;
@ -211,6 +247,17 @@ export const usePTT = (
setMicMuteWrapper(true); setMicMuteWrapper(true);
}, [setMicMuteWrapper]); }, [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(() => { useEffect(() => {
function onKeyDown(event: KeyboardEvent): void { function onKeyDown(event: KeyboardEvent): void {
if (event.code === "Space") { if (event.code === "Space") {
@ -260,8 +307,18 @@ export const usePTT = (
pttButtonHeld, pttButtonHeld,
enablePTTButton, enablePTTButton,
setMicMuteWrapper, setMicMuteWrapper,
client,
onClientSync,
]); ]);
useEffect(() => {
client.on(ClientEvent.Sync, onClientSync);
return () => {
client.removeListener(ClientEvent.Sync, onClientSync);
};
}, [client, onClientSync]);
const setTalkOverEnabled = useCallback((talkOverEnabled) => { const setTalkOverEnabled = useCallback((talkOverEnabled) => {
setState((prevState) => ({ setState((prevState) => ({
...prevState, ...prevState,
@ -275,8 +332,10 @@ export const usePTT = (
talkOverEnabled, talkOverEnabled,
setTalkOverEnabled, setTalkOverEnabled,
activeSpeakerUserId, activeSpeakerUserId,
activeSpeakerVolume,
startTalking, startTalking,
stopTalking, stopTalking,
transmitBlocked, transmitBlocked,
connected,
}; };
}; };

View file

@ -24,12 +24,13 @@ import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
import { SelectInput } from "../input/SelectInput"; import { SelectInput } from "../input/SelectInput";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { useMediaHandler } from "./useMediaHandler"; import { useMediaHandler } from "./useMediaHandler";
import { useSpatialAudio, useShowInspector } from "./useSetting";
import { FieldRow, InputField } from "../input/Input"; import { FieldRow, InputField } from "../input/Input";
import { Button } from "../button"; import { Button } from "../button";
import { useDownloadDebugLog } from "./submit-rageshake"; import { useDownloadDebugLog } from "./submit-rageshake";
import { Body } from "../typography/Typography"; import { Body } from "../typography/Typography";
export function SettingsModal({ setShowInspector, showInspector, ...rest }) { export const SettingsModal = (props) => {
const { const {
audioInput, audioInput,
audioInputs, audioInputs,
@ -41,6 +42,8 @@ export function SettingsModal({ setShowInspector, showInspector, ...rest }) {
audioOutputs, audioOutputs,
setAudioOutput, setAudioOutput,
} = useMediaHandler(); } = useMediaHandler();
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
const [showInspector, setShowInspector] = useShowInspector();
const downloadDebugLog = useDownloadDebugLog(); const downloadDebugLog = useDownloadDebugLog();
@ -50,7 +53,7 @@ export function SettingsModal({ setShowInspector, showInspector, ...rest }) {
isDismissable isDismissable
mobileFullScreen mobileFullScreen
className={styles.settingsModal} className={styles.settingsModal}
{...rest} {...props}
> >
<TabContainer className={styles.tabContainer}> <TabContainer className={styles.tabContainer}>
<TabItem <TabItem
@ -81,6 +84,15 @@ export function SettingsModal({ setShowInspector, showInspector, ...rest }) {
))} ))}
</SelectInput> </SelectInput>
)} )}
<FieldRow>
<InputField
id="spatialAudio"
label="Spatial audio (experimental)"
type="checkbox"
checked={spatialAudio}
onChange={(e) => setSpatialAudio(e.target.checked)}
/>
</FieldRow>
</TabItem> </TabItem>
<TabItem <TabItem
title={ title={
@ -130,4 +142,4 @@ export function SettingsModal({ setShowInspector, showInspector, ...rest }) {
</TabContainer> </TabContainer>
</Modal> </Modal>
); );
} };

View file

@ -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 = <T>(
name: string,
defaultValue: T
): [T, (value: T) => void] => {
const key = useMemo(() => `matrix-setting-${name}`, [name]);
const [value, setValue] = useState<T>(() => {
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);

View file

@ -14,57 +14,63 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { forwardRef } from "react";
import { animated } from "@react-spring/web"; import { animated } from "@react-spring/web";
import classNames from "classnames"; import classNames from "classnames";
import styles from "./VideoTile.module.css"; import styles from "./VideoTile.module.css";
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg"; import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg"; import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg";
export function VideoTile({ export const VideoTile = forwardRef(
className, (
isLocal, {
speaking, className,
audioMuted, isLocal,
noVideo, speaking,
videoMuted, audioMuted,
screenshare, noVideo,
avatar, videoMuted,
name, screenshare,
showName, avatar,
mediaRef, name,
...rest showName,
}) { mediaRef,
return ( ...rest
<animated.div },
className={classNames(styles.videoTile, className, { ref
[styles.isLocal]: isLocal, ) => {
[styles.speaking]: speaking, return (
[styles.muted]: audioMuted, <animated.div
[styles.screenshare]: screenshare, className={classNames(styles.videoTile, className, {
})} [styles.isLocal]: isLocal,
{...rest} [styles.speaking]: speaking,
> [styles.muted]: audioMuted,
{(videoMuted || noVideo) && ( [styles.screenshare]: screenshare,
<> })}
<div className={styles.videoMutedOverlay} /> ref={ref}
{avatar} {...rest}
</> >
)} {(videoMuted || noVideo) && (
{screenshare ? ( <>
<div className={styles.presenterLabel}> <div className={styles.videoMutedOverlay} />
<span>{`${name} is presenting`}</span> {avatar}
</div> </>
) : ( )}
(showName || audioMuted || (videoMuted && !noVideo)) && ( {screenshare ? (
<div className={styles.memberName}> <div className={styles.presenterLabel}>
{audioMuted && !(videoMuted && !noVideo) && <MicMutedIcon />} <span>{`${name} is presenting`}</span>
{videoMuted && !noVideo && <VideoMutedIcon />}
{showName && <span title={name}>{name}</span>}
</div> </div>
) ) : (
)} (showName || audioMuted || (videoMuted && !noVideo)) && (
<video ref={mediaRef} playsInline disablePictureInPicture /> <div className={styles.memberName}>
</animated.div> {audioMuted && !(videoMuted && !noVideo) && <MicMutedIcon />}
); {videoMuted && !noVideo && <VideoMutedIcon />}
} {showName && <span title={name}>{name}</span>}
</div>
)
)}
<video ref={mediaRef} playsInline disablePictureInPicture />
</animated.div>
);
}
);

View file

@ -5,6 +5,10 @@
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
touch-action: none; 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 * { .videoTile * {

View file

@ -17,7 +17,7 @@ limitations under the License.
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes"; import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
import React from "react"; import React from "react";
import { useCallFeed } from "./useCallFeed"; import { useCallFeed } from "./useCallFeed";
import { useMediaStream } from "./useMediaStream"; import { useSpatialMediaStream } from "./useMediaStream";
import { useRoomMemberName } from "./useRoomMemberName"; import { useRoomMemberName } from "./useRoomMemberName";
import { VideoTile } from "./VideoTile"; import { VideoTile } from "./VideoTile";
@ -28,6 +28,7 @@ export function VideoTileContainer({
getAvatar, getAvatar,
showName, showName,
audioOutputDevice, audioOutputDevice,
audioContext,
disableSpeakingIndicator, disableSpeakingIndicator,
...rest ...rest
}) { }) {
@ -42,7 +43,12 @@ export function VideoTileContainer({
member, member,
} = useCallFeed(item.callFeed); } = useCallFeed(item.callFeed);
const { rawDisplayName } = useRoomMemberName(member); 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 // Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831 // https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
@ -57,6 +63,7 @@ export function VideoTileContainer({
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare} screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
name={rawDisplayName} name={rawDisplayName}
showName={showName} showName={showName}
ref={tileRef}
mediaRef={mediaRef} mediaRef={mediaRef}
avatar={getAvatar && getAvatar(member, width, height)} avatar={getAvatar && getAvatar(member, width, height)}
{...rest} {...rest}

View file

@ -16,6 +16,8 @@ limitations under the License.
import { useRef, useEffect } from "react"; import { useRef, useEffect } from "react";
import { useSpatialAudio } from "../settings/useSetting";
export function useMediaStream(stream, audioOutputDevice, mute = false) { export function useMediaStream(stream, audioOutputDevice, mute = false) {
const mediaRef = useRef(); const mediaRef = useRef();
@ -55,7 +57,8 @@ export function useMediaStream(stream, audioOutputDevice, mute = false) {
mediaRef.current !== undefined mediaRef.current !== undefined
) { ) {
console.log(`useMediaStream setSinkId ${audioOutputDevice}`); console.log(`useMediaStream setSinkId ${audioOutputDevice}`);
mediaRef.current.setSinkId(audioOutputDevice); // Chrome for Android doesn't support this
mediaRef.current.setSinkId?.(audioOutputDevice);
} }
}, [audioOutputDevice]); }, [audioOutputDevice]);
@ -73,3 +76,69 @@ export function useMediaStream(stream, audioOutputDevice, mute = false) {
return mediaRef; 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];
};

View file

@ -2876,6 +2876,11 @@
"@types/minimatch" "*" "@types/minimatch" "*"
"@types/node" "*" "@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": "@types/hast@^2.0.0":
version "2.3.4" version "2.3.4"
resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc" resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc"