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",
},
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: [

View file

@ -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",

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");
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<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 [
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
setState,
] = useState({
] = useState<ClientProviderState>({
loading: true,
isAuthenticated: false,
isPasswordlessUser: false,
@ -43,18 +86,16 @@ export function ClientProvider({ children }) {
});
useEffect(() => {
async function restore() {
const restore = async (): Promise<
Pick<ClientProviderState, "client" | "isPasswordlessUser">
> => {
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<ClientState>(
() => ({
loading,
isAuthenticated,
@ -258,8 +274,6 @@ export function ClientProvider({ children }) {
return (
<ClientContext.Provider value={context}>{children}</ClientContext.Provider>
);
}
};
export function useClient() {
return useContext(ClientContext);
}
export const useClient = () => 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");
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<HTMLInputElement>();
const passwordRef = useRef<HTMLInputElement>();
const history = useHistory();
const location = useLocation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [error, setError] = useState<Error>();
// TODO: Handle hitting login page with authenticated client
const onSubmitLoginForm = useCallback(
(e) => {
(e: FormEvent<HTMLFormElement>) => {
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() {
</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");
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<HTMLInputElement>();
const history = useHistory();
const location = useLocation();
const [registering, setRegistering] = useState(false);
const [error, setError] = useState();
const [error, setError] = useState<Error>();
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<HTMLFormElement>) => {
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 <LoadingView />;
@ -218,4 +223,4 @@ export function RegisterPage() {
</div>
</>
);
}
};

View file

@ -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];
}

View file

@ -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<string>();
const [recaptchaKey, setRecaptchaKey] = useState<string>();
const authClientRef = useRef();
const authClient = useRef<MatrixClient>();
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];
};

View file

@ -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<RecaptchaPromiseRef>();
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<HTMLIFrameElement>(
'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 };
}
};

View file

@ -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();

View file

@ -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}`);
});

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,
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;

View file

@ -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 {

View file

@ -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}
/>
);

View file

@ -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({
<OverflowMenu
inCall
roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client}
groupCall={groupCall}
showInvite={true}

View file

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

View file

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

View file

@ -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);
}

View file

@ -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<Props> = ({
activeSpeakerDisplayName,
activeSpeakerAvatarUrl,
activeSpeakerIsLocalUser,
activeSpeakerVolume,
size,
startTalking,
stopTalking,
@ -130,12 +133,32 @@ export const PTTButton: React.FC<Props> = ({
);
};
}, [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 (
<button
<animated.button
className={classNames(styles.pttButton, {
[styles.talking]: activeSpeakerUserId,
[styles.error]: showTalkOverError,
})}
style={{
boxShadow: shadow.to(
(s) =>
`0px 0px 0px ${s}px ${shadowColor}, 0px 0px 0px ${
2 * s
}px ${shadowColor}`
),
}}
onMouseDown={onButtonMouseDown}
ref={buttonRef}
>
@ -154,6 +177,6 @@ export const PTTButton: React.FC<Props> = ({
className={styles.avatar}
/>
)}
</button>
</animated.button>
);
};

View file

@ -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<Props> = ({
@ -97,8 +98,6 @@ export const PTTCallView: React.FC<Props> = ({
participants,
userMediaFeeds,
onLeave,
setShowInspector,
showInspector,
}) => {
const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState();
@ -124,9 +123,11 @@ export const PTTCallView: React.FC<Props> = ({
talkOverEnabled,
setTalkOverEnabled,
activeSpeakerUserId,
activeSpeakerVolume,
startTalking,
stopTalking,
transmitBlocked,
connected,
} = usePTT(
client,
groupCall,
@ -189,8 +190,6 @@ export const PTTCallView: React.FC<Props> = ({
<OverflowMenu
inCall
roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client}
groupCall={groupCall}
showInvite={false}
@ -223,6 +222,7 @@ export const PTTCallView: React.FC<Props> = ({
activeSpeakerDisplayName={activeSpeakerDisplayName}
activeSpeakerAvatarUrl={activeSpeakerAvatarUrl}
activeSpeakerIsLocalUser={activeSpeakerIsLocalUser}
activeSpeakerVolume={activeSpeakerVolume}
size={pttButtonSize}
startTalking={startTalking}
stopTalking={stopTalking}
@ -234,7 +234,8 @@ export const PTTCallView: React.FC<Props> = ({
activeSpeakerIsLocalUser,
talkOverEnabled,
activeSpeakerUserId,
activeSpeakerDisplayName
activeSpeakerDisplayName,
connected
)}
</p>
{userMediaFeeds.map((callFeed) => (

View file

@ -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);

View file

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

View file

@ -18,10 +18,57 @@ import { useCallback, useEffect, useState } from "react";
import {
GroupCallEvent,
GroupCallState,
GroupCall,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { usePageUnload } from "./usePageUnload";
export function useGroupCall(groupCall) {
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 [
{
state,
@ -41,20 +88,25 @@ export function useGroupCall(groupCall) {
requestingScreenshare,
},
setState,
] = useState({
] = useState<State>({
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<State>) =>
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(),

View file

@ -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,
};
};

View file

@ -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}
>
<TabContainer className={styles.tabContainer}>
<TabItem
@ -81,6 +84,15 @@ export function SettingsModal({ setShowInspector, showInspector, ...rest }) {
))}
</SelectInput>
)}
<FieldRow>
<InputField
id="spatialAudio"
label="Spatial audio (experimental)"
type="checkbox"
checked={spatialAudio}
onChange={(e) => setSpatialAudio(e.target.checked)}
/>
</FieldRow>
</TabItem>
<TabItem
title={
@ -130,4 +142,4 @@ export function SettingsModal({ setShowInspector, showInspector, ...rest }) {
</TabContainer>
</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.
*/
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 (
<animated.div
className={classNames(styles.videoTile, className, {
[styles.isLocal]: isLocal,
[styles.speaking]: speaking,
[styles.muted]: audioMuted,
[styles.screenshare]: screenshare,
})}
{...rest}
>
{(videoMuted || noVideo) && (
<>
<div className={styles.videoMutedOverlay} />
{avatar}
</>
)}
{screenshare ? (
<div className={styles.presenterLabel}>
<span>{`${name} is presenting`}</span>
</div>
) : (
(showName || audioMuted || (videoMuted && !noVideo)) && (
<div className={styles.memberName}>
{audioMuted && !(videoMuted && !noVideo) && <MicMutedIcon />}
{videoMuted && !noVideo && <VideoMutedIcon />}
{showName && <span title={name}>{name}</span>}
export const VideoTile = forwardRef(
(
{
className,
isLocal,
speaking,
audioMuted,
noVideo,
videoMuted,
screenshare,
avatar,
name,
showName,
mediaRef,
...rest
},
ref
) => {
return (
<animated.div
className={classNames(styles.videoTile, className, {
[styles.isLocal]: isLocal,
[styles.speaking]: speaking,
[styles.muted]: audioMuted,
[styles.screenshare]: screenshare,
})}
ref={ref}
{...rest}
>
{(videoMuted || noVideo) && (
<>
<div className={styles.videoMutedOverlay} />
{avatar}
</>
)}
{screenshare ? (
<div className={styles.presenterLabel}>
<span>{`${name} is presenting`}</span>
</div>
)
)}
<video ref={mediaRef} playsInline disablePictureInPicture />
</animated.div>
);
}
) : (
(showName || audioMuted || (videoMuted && !noVideo)) && (
<div className={styles.memberName}>
{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;
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 * {

View file

@ -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}

View file

@ -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];
};

View file

@ -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"