Merge remote-tracking branch 'upstream/livekit' into SimonBrandner/feat/friendly-url

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner
2023-07-15 09:48:08 +02:00
113 changed files with 2493 additions and 1375 deletions

View File

@@ -18,6 +18,7 @@ import { Suspense, useEffect, useState } from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { OverlayProvider } from "@react-aria/overlays";
import { History } from "history";
import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
@@ -50,6 +51,8 @@ export default function App({ history }: AppProps) {
const errorPage = <CrashView />;
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<Router history={history}>
{loaded ? (
<Suspense fallback={null}>

View File

@@ -16,7 +16,6 @@ limitations under the License.
import { useMemo, CSSProperties, HTMLAttributes, FC } from "react";
import classNames from "classnames";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { getAvatarUrl } from "./matrix-utils";
import { useClient } from "./ClientContext";
@@ -59,9 +58,6 @@ function hashStringToArrIndex(str: string, arrLength: number) {
return sum % arrLength;
}
const resolveAvatarSrc = (client: MatrixClient, src: string, size: number) =>
src?.startsWith("mxc://") ? client && getAvatarUrl(client, src, size) : src;
interface Props extends HTMLAttributes<HTMLDivElement> {
bgKey?: string;
src?: string;
@@ -99,10 +95,10 @@ export const Avatar: FC<Props> = ({
[size]
);
const resolvedSrc = useMemo(
() => resolveAvatarSrc(client, src, sizePx),
[client, src, sizePx]
);
const resolvedSrc = useMemo(() => {
if (!client || !src || !sizePx) return undefined;
return src.startsWith("mxc://") ? getAvatarUrl(client, src, sizePx) : src;
}, [client, src, sizePx]);
const backgroundColor = useMemo(() => {
const index = hashStringToArrIndex(

22
src/Banner.module.css Normal file
View File

@@ -0,0 +1,22 @@
/*
Copyright 2023 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.
*/
.banner {
flex: 1;
border-radius: 8px;
padding: 16px;
background-color: var(--subtle-primary);
}

27
src/Banner.tsx Normal file
View File

@@ -0,0 +1,27 @@
/*
Copyright 2023 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 { ReactNode } from "react";
import styles from "./Banner.module.css";
interface Props {
children: ReactNode;
}
export const Banner = ({ children }: Props) => {
return <div className={styles.banner}>{children}</div>;
};

View File

@@ -20,9 +20,9 @@ import {
useEffect,
useState,
createContext,
useMemo,
useContext,
useRef,
useMemo,
} from "react";
import { useHistory } from "react-router-dom";
import { MatrixClient } from "matrix-js-sdk/src/client";
@@ -31,9 +31,9 @@ import { useTranslation } from "react-i18next";
import { ErrorView } from "./FullScreenView";
import {
initClient,
CryptoStoreIntegrityError,
fallbackICEServerAllowed,
initClient,
} from "./matrix-utils";
import { widget } from "./widget";
import {
@@ -47,184 +47,141 @@ import { Config } from "./config/Config";
declare global {
interface Window {
matrixclient: MatrixClient;
isPasswordlessUser: boolean;
passwordlessUser: boolean;
}
}
export interface Session {
user_id: string;
device_id: string;
access_token: string;
export type ClientState = ValidClientState | ErrorState;
export type ValidClientState = {
state: "valid";
authenticated?: AuthenticatedClient;
setClient: (params?: SetClientParams) => void;
};
export type AuthenticatedClient = {
client: MatrixClient;
isPasswordlessUser: boolean;
changePassword: (password: string) => Promise<void>;
logout: () => void;
};
export type ErrorState = {
state: "error";
error: Error;
};
export type SetClientParams = {
client: MatrixClient;
session: Session;
};
const ClientContext = createContext<ClientState | undefined>(undefined);
export const useClientState = () => useContext(ClientContext);
export function useClient(): {
client?: MatrixClient;
setClient?: (params?: SetClientParams) => void;
} {
let client;
let setClient;
const clientState = useClientState();
if (clientState?.state === "valid") {
client = clientState.authenticated?.client;
setClient = clientState.setClient;
}
return { client, setClient };
}
// Plain representation of the `ClientContext` as a helper for old components that expected an object with multiple fields.
export function useClientLegacy(): {
client?: MatrixClient;
setClient?: (params?: SetClientParams) => void;
passwordlessUser: boolean;
tempPassword?: string;
loading: boolean;
authenticated: boolean;
logout?: () => void;
error?: Error;
} {
const clientState = useClientState();
let client;
let setClient;
let passwordlessUser = false;
let loading = true;
let error;
let authenticated = false;
let logout;
if (clientState?.state === "valid") {
client = clientState.authenticated?.client;
setClient = clientState.setClient;
passwordlessUser = clientState.authenticated?.isPasswordlessUser ?? false;
loading = false;
authenticated = client !== undefined;
logout = clientState.authenticated?.logout;
} else if (clientState?.state === "error") {
error = clientState.error;
loading = false;
}
return {
client,
setClient,
passwordlessUser,
loading,
authenticated,
logout,
error,
};
}
const loadChannel =
"BroadcastChannel" in window ? new BroadcastChannel("load") : null;
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;
error?: Error;
}
const ClientContext = createContext<ClientState>(null);
type ClientProviderState = Omit<
ClientState,
"changePassword" | "logout" | "setClient"
> & { error?: Error };
interface Props {
children: JSX.Element;
}
export const ClientProvider: FC<Props> = ({ children }) => {
const history = useHistory();
const initializing = useRef(false);
const [
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
setState,
] = useState<ClientProviderState>({
loading: true,
isAuthenticated: false,
isPasswordlessUser: false,
client: undefined,
userName: null,
error: undefined,
});
const [initClientState, setInitClientState] = useState<
InitResult | undefined
>(undefined);
const initializing = useRef(false);
useEffect(() => {
// In case the component is mounted, unmounted, and remounted quickly (as
// React does in strict mode), we need to make sure not to doubly initialize
// the client
// the client.
if (initializing.current) return;
initializing.current = true;
const init = async (): Promise<
Pick<ClientProviderState, "client" | "isPasswordlessUser">
> => {
if (widget) {
// We're inside a widget, so let's engage *matryoshka mode*
logger.log("Using a matryoshka client");
return {
client: await widget.client,
isPasswordlessUser: false,
};
} else {
// We're running as a standalone application
try {
const session = loadSession();
if (!session) return { client: undefined, isPasswordlessUser: false };
logger.log("Using a standalone client");
/* eslint-disable camelcase */
const { user_id, device_id, access_token, passwordlessUser } =
session;
const livekit = Config.get().livekit;
const foci = livekit
? [
{
livekitServiceUrl: livekit.livekit_service_url,
},
]
: undefined;
try {
return {
client: await initClient(
{
baseUrl: Config.defaultHomeserverUrl(),
accessToken: access_token,
userId: user_id,
deviceId: device_id,
fallbackICEServerAllowed: fallbackICEServerAllowed,
foci,
},
true
),
isPasswordlessUser: passwordlessUser,
};
} catch (err) {
if (err instanceof CryptoStoreIntegrityError) {
// We can't use this session anymore, so let's log it out
try {
const client = await initClient(
{
baseUrl: Config.defaultHomeserverUrl(),
accessToken: access_token,
userId: user_id,
deviceId: device_id,
fallbackICEServerAllowed: fallbackICEServerAllowed,
foci,
},
false // Don't need the crypto store just to log out
);
await client.logout(true);
} catch (err_) {
logger.warn(
"The previous session was lost, and we couldn't log it out, " +
"either"
);
}
}
throw err;
}
/* eslint-enable camelcase */
} catch (err) {
clearSession();
throw err;
loadClient()
.then((maybeClient) => {
if (!maybeClient) {
logger.error("Failed to initialize client");
return;
}
}
};
init()
.then(({ client, isPasswordlessUser }) => {
setState({
client,
loading: false,
isAuthenticated: Boolean(client),
isPasswordlessUser,
userName: client?.getUserIdLocalpart(),
error: undefined,
});
})
.catch((err) => {
logger.error(err);
setState({
client: undefined,
loading: false,
isAuthenticated: false,
isPasswordlessUser: false,
userName: null,
error: undefined,
});
setInitClientState(maybeClient);
})
.catch((err) => logger.error(err))
.finally(() => (initializing.current = false));
}, []);
const changePassword = useCallback(
async (password: string) => {
const { tempPassword, ...session } = loadSession();
const session = loadSession();
if (!initClientState?.client || !session) {
return;
}
await client.setPassword(
await initClientState.client.setPassword(
{
type: "m.login.password",
identifier: {
@@ -232,73 +189,56 @@ export const ClientProvider: FC<Props> = ({ children }) => {
user: session.user_id,
},
user: session.user_id,
password: tempPassword,
password: session.tempPassword,
},
password
);
saveSession({ ...session, passwordlessUser: false });
setState({
client,
loading: false,
isAuthenticated: true,
isPasswordlessUser: false,
userName: client.getUserIdLocalpart(),
error: undefined,
setInitClientState({
client: initClientState.client,
passwordlessUser: false,
});
},
[client]
[initClientState?.client]
);
const setClient = useCallback(
(newClient: MatrixClient, session: Session) => {
if (client && client !== newClient) {
client.stopClient();
(clientParams?: SetClientParams) => {
const oldClient = initClientState?.client;
const newClient = clientParams?.client;
if (oldClient && oldClient !== newClient) {
oldClient.stopClient();
}
if (newClient) {
saveSession(session);
setState({
client: newClient,
loading: false,
isAuthenticated: true,
isPasswordlessUser: session.passwordlessUser,
userName: newClient.getUserIdLocalpart(),
error: undefined,
if (clientParams) {
saveSession(clientParams.session);
setInitClientState({
client: clientParams.client,
passwordlessUser: clientParams.session.passwordlessUser,
});
} else {
clearSession();
setState({
client: undefined,
loading: false,
isAuthenticated: false,
isPasswordlessUser: false,
userName: null,
error: undefined,
});
setInitClientState(undefined);
}
},
[client]
[initClientState?.client]
);
const logout = useCallback(async () => {
const client = initClientState?.client;
if (!client) {
return;
}
await client.logout(true);
await client.clearStores();
clearSession();
setState({
client: undefined,
loading: false,
isAuthenticated: false,
isPasswordlessUser: true,
userName: "",
error: undefined,
});
setInitClientState(undefined);
history.push("/");
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
}, [history, client]);
}, [history, initClientState?.client]);
const { t } = useTranslation();
@@ -310,61 +250,140 @@ export const ClientProvider: FC<Props> = ({ children }) => {
if (!widget) loadChannel?.postMessage({});
}, []);
const [alreadyOpenedErr, setAlreadyOpenedErr] = useState<Error | undefined>(
undefined
);
useEventTarget(
loadChannel,
"message",
useCallback(() => {
client?.stopClient();
setState((prev) => ({
...prev,
error: translatedError(
"This application has been opened in another tab.",
t
),
}));
}, [client, setState, t])
initClientState?.client.stopClient();
setAlreadyOpenedErr(
translatedError("This application has been opened in another tab.", t)
);
}, [initClientState?.client, setAlreadyOpenedErr, t])
);
const context = useMemo<ClientState>(
() => ({
loading,
isAuthenticated,
isPasswordlessUser,
client,
changePassword,
logout,
userName,
setClient,
error: undefined,
}),
[
loading,
isAuthenticated,
isPasswordlessUser,
client,
changePassword,
logout,
userName,
setClient,
]
);
const state: ClientState = useMemo(() => {
if (alreadyOpenedErr) {
return { state: "error", error: alreadyOpenedErr };
}
let authenticated = undefined;
if (initClientState) {
authenticated = {
client: initClientState.client,
isPasswordlessUser: initClientState.passwordlessUser,
changePassword,
logout,
};
}
return { state: "valid", authenticated, setClient };
}, [alreadyOpenedErr, changePassword, initClientState, logout, setClient]);
useEffect(() => {
window.matrixclient = client;
window.isPasswordlessUser = isPasswordlessUser;
if (!initClientState) {
return;
}
window.matrixclient = initClientState.client;
window.passwordlessUser = initClientState.passwordlessUser;
if (PosthogAnalytics.hasInstance())
PosthogAnalytics.instance.onLoginStatusChanged();
}, [client, isPasswordlessUser]);
}, [initClientState]);
if (error) {
return <ErrorView error={error} />;
if (alreadyOpenedErr) {
return <ErrorView error={alreadyOpenedErr} />;
}
return (
<ClientContext.Provider value={context}>{children}</ClientContext.Provider>
<ClientContext.Provider value={state}>{children}</ClientContext.Provider>
);
};
export const useClient = () => useContext(ClientContext);
type InitResult = {
client: MatrixClient;
passwordlessUser: boolean;
};
async function loadClient(): Promise<InitResult> {
if (widget) {
// We're inside a widget, so let's engage *matryoshka mode*
logger.log("Using a matryoshka client");
const client = await widget.client;
return {
client,
passwordlessUser: false,
};
} else {
// We're running as a standalone application
try {
const session = loadSession();
if (!session) {
throw new Error("No session stored");
}
logger.log("Using a standalone client");
/* eslint-disable camelcase */
const { user_id, device_id, access_token, passwordlessUser } = session;
const initClientParams = {
baseUrl: Config.defaultHomeserverUrl()!,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
fallbackICEServerAllowed: fallbackICEServerAllowed,
livekitServiceURL: Config.get().livekit!.livekit_service_url,
};
try {
const client = await initClient(initClientParams, true);
return {
client,
passwordlessUser,
};
} catch (err) {
if (err instanceof CryptoStoreIntegrityError) {
// We can't use this session anymore, so let's log it out
try {
const client = await initClient(initClientParams, false); // Don't need the crypto store just to log out)
await client.logout(true);
} catch (err) {
logger.warn(
"The previous session was lost, and we couldn't log it out, " +
err +
"either"
);
}
}
throw err;
}
/* eslint-enable camelcase */
} catch (err) {
clearSession();
throw err;
}
}
}
export interface Session {
user_id: string;
device_id: string;
access_token: string;
passwordlessUser: boolean;
tempPassword?: string;
}
const clearSession = () => localStorage.removeItem("matrix-auth-store");
const saveSession = (s: Session) =>
localStorage.setItem("matrix-auth-store", JSON.stringify(s));
const loadSession = (): Session | undefined => {
const data = localStorage.getItem("matrix-auth-store");
if (!data) {
return undefined;
}
return JSON.parse(data);
};

23
src/E2EEBanner.module.css Normal file
View File

@@ -0,0 +1,23 @@
/*
Copyright 2023 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.
*/
.e2eeBanner {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
font-size: var(--font-size-caption);
}

35
src/E2EEBanner.tsx Normal file
View File

@@ -0,0 +1,35 @@
/*
Copyright 2023 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 { Trans } from "react-i18next";
import { Banner } from "./Banner";
import styles from "./E2EEBanner.module.css";
import { ReactComponent as LockOffIcon } from "./icons/LockOff.svg";
export const E2EEBanner = () => {
return (
<Banner>
<div className={styles.e2eeBanner}>
<LockOffIcon width={24} height={24} />
<Trans>
Element Call is temporarily not end-to-end encrypted while we test
scalability.
</Trans>
</div>
</Banner>
);
};

28
src/E2EELock.module.css Normal file
View File

@@ -0,0 +1,28 @@
/*
Copyright 2023 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.
*/
.e2eeLock {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
margin: 8px;
border-radius: 100%;
background-color: var(--subtle-primary);
}

58
src/E2EELock.tsx Normal file
View File

@@ -0,0 +1,58 @@
/*
Copyright 2023 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 { useTranslation } from "react-i18next";
import { useCallback } from "react";
import { useObjectRef } from "@react-aria/utils";
import { useButton } from "@react-aria/button";
import styles from "./E2EELock.module.css";
import { ReactComponent as LockOffIcon } from "./icons/LockOff.svg";
import { TooltipTrigger } from "./Tooltip";
export const E2EELock = () => {
const { t } = useTranslation();
const tooltip = useCallback(
() =>
t(
"Element Call is temporarily not end-to-end encrypted while we test scalability."
),
[t]
);
return (
<TooltipTrigger placement="right" tooltip={tooltip}>
<Icon />
</TooltipTrigger>
);
};
/**
* This component is a bit of hack - for some reason for the TooltipTrigger to
* work, it needs to contain a component which uses the useButton hook; please
* note that for some reason this also needs to be a separate component and we
* cannot just use the useButton hook inside the E2EELock.
*/
const Icon = () => {
const buttonRef = useObjectRef<HTMLDivElement>();
const { buttonProps } = useButton({}, buttonRef);
return (
<div ref={buttonRef} className={styles.e2eeLock} {...buttonProps}>
<LockOffIcon />
</div>
);
};

View File

@@ -47,8 +47,8 @@ export function Facepile({
}: Props) {
const { t } = useTranslation();
const _size = sizes.get(size);
const _overlap = overlapMap[size];
const _size = sizes.get(size)!;
const _overlap = overlapMap[size]!;
const title = useMemo(() => {
return members.reduce<string | null>(

View File

@@ -36,15 +36,16 @@ export function ListBox<T>({
listBoxRef,
...rest
}: ListBoxProps<T>) {
const ref = useRef<HTMLUListElement>();
if (!listBoxRef) listBoxRef = ref;
const ref = useRef<HTMLUListElement>(null);
const { listBoxProps } = useListBox(rest, state, listBoxRef);
const listRef = listBoxRef ?? ref;
const { listBoxProps } = useListBox(rest, state, listRef);
return (
<ul
{...listBoxProps}
ref={listBoxRef}
ref={listRef}
className={classNames(styles.listBox, className)}
>
{[...state.collection].map((item) => (
@@ -66,7 +67,7 @@ interface OptionProps<T> {
}
function Option<T>({ item, state, className }: OptionProps<T>) {
const ref = useRef();
const ref = useRef(null);
const { optionProps, isSelected, isFocused, isDisabled } = useOption(
{ key: item.key },
state,
@@ -83,7 +84,11 @@ function Option<T>({ item, state, className }: OptionProps<T>) {
const origPointerUp = optionProps.onPointerUp;
delete optionProps.onPointerUp;
optionProps.onClick = useCallback(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
origPointerUp(e as unknown as PointerEvent<HTMLElement>);
},
[origPointerUp]

View File

@@ -26,7 +26,7 @@ import styles from "./Menu.module.css";
interface MenuProps<T> extends AriaMenuOptions<T> {
className?: String;
onClose?: () => void;
onClose: () => void;
onAction: (value: Key) => void;
label?: string;
}
@@ -39,7 +39,7 @@ export function Menu<T extends object>({
...rest
}: MenuProps<T>) {
const state = useTreeState<T>({ ...rest, selectionMode: "none" });
const menuRef = useRef();
const menuRef = useRef(null);
const { menuProps } = useMenu<T>(rest, state, menuRef);
return (
@@ -69,7 +69,7 @@ interface MenuItemProps<T> {
}
function MenuItem<T>({ item, state, onAction, onClose }: MenuItemProps<T>) {
const ref = useRef();
const ref = useRef(null);
const { menuItemProps } = useMenuItem(
{
key: item.key,

View File

@@ -55,7 +55,7 @@ export function Modal({
...rest
}: ModalProps) {
const { t } = useTranslation();
const modalRef = useRef();
const modalRef = useRef(null);
const { overlayProps, underlayProps } = useOverlay(
{ ...rest, onClose },
modalRef
@@ -63,7 +63,7 @@ export function Modal({
usePreventScroll();
const { modalProps } = useModal();
const { dialogProps, titleProps } = useDialog(rest, modalRef);
const closeButtonRef = useRef();
const closeButtonRef = useRef(null);
const { buttonProps: closeButtonProps } = useButton(
{
onPress: () => onClose(),

View File

@@ -36,6 +36,9 @@ export function SequenceDiagramViewerPage() {
const [debugLog, setDebugLog] = useState<DebugLog>();
const [selectedUserId, setSelectedUserId] = useState<string>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const onChangeDebugLog = useCallback((e) => {
if (e.target.files && e.target.files.length > 0) {
e.target.files[0].text().then((text: string) => {
@@ -55,7 +58,7 @@ export function SequenceDiagramViewerPage() {
onChange={onChangeDebugLog}
/>
</FieldRow>
{debugLog && (
{debugLog && selectedUserId && (
<SequenceDiagramViewer
localUserId={debugLog.localUserId}
selectedUserId={selectedUserId}

View File

@@ -74,7 +74,7 @@ export const TooltipTrigger = forwardRef<HTMLElement, TooltipTriggerProps>(
const tooltipTriggerProps = { delay: 250, ...rest };
const tooltipState = useTooltipTriggerState(tooltipTriggerProps);
const triggerRef = useObjectRef<HTMLElement>(ref);
const overlayRef = useRef();
const overlayRef = useRef<HTMLDivElement>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
tooltipTriggerProps,
tooltipState,

View File

@@ -36,7 +36,7 @@ interface UserMenuProps {
isAuthenticated: boolean;
isPasswordlessUser: boolean;
displayName: string;
avatarUrl: string;
avatarUrl?: string;
onAction: (value: string) => void;
}
@@ -119,21 +119,24 @@ export function UserMenu({
)}
</Button>
</TooltipTrigger>
{(props) => (
<Menu {...props} label={t("User menu")} onAction={onAction}>
{items.map(({ key, icon: Icon, label, dataTestid }) => (
<Item key={key} textValue={label}>
<Icon
width={24}
height={24}
className={styles.menuIcon}
data-testid={dataTestid}
/>
<Body overflowEllipsis>{label}</Body>
</Item>
))}
</Menu>
)}
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(props: any) => (
<Menu {...props} label={t("User menu")} onAction={onAction}>
{items.map(({ key, icon: Icon, label, dataTestid }) => (
<Item key={key} textValue={label}>
<Icon
width={24}
height={24}
className={styles.menuIcon}
data-testid={dataTestid}
/>
<Body overflowEllipsis>{label}</Body>
</Item>
))}
</Menu>
)
}
</PopoverMenuTrigger>
);
}

View File

@@ -17,7 +17,7 @@ limitations under the License.
import { useCallback, useState } from "react";
import { useHistory, useLocation } from "react-router-dom";
import { useClient } from "./ClientContext";
import { useClientLegacy } from "./ClientContext";
import { useProfile } from "./profile/useProfile";
import { useModalTriggerState } from "./Modal";
import { SettingsModal } from "./settings/SettingsModal";
@@ -30,8 +30,7 @@ interface Props {
export function UserMenuContainer({ preventNavigation = false }: Props) {
const location = useLocation();
const history = useHistory();
const { isAuthenticated, isPasswordlessUser, logout, userName, client } =
useClient();
const { client, logout, authenticated, passwordlessUser } = useClientLegacy();
const { displayName, avatarUrl } = useProfile(client);
const { modalState, modalProps } = useModalTriggerState();
@@ -49,7 +48,7 @@ export function UserMenuContainer({ preventNavigation = false }: Props) {
modalState.open();
break;
case "logout":
logout();
logout?.();
break;
case "login":
history.push("/login", { state: { from: location } });
@@ -59,19 +58,18 @@ export function UserMenuContainer({ preventNavigation = false }: Props) {
[history, location, logout, modalState]
);
const userName = client?.getUserIdLocalpart() ?? "";
return (
<>
<UserMenu
preventNavigation={preventNavigation}
isAuthenticated={isAuthenticated}
isPasswordlessUser={isPasswordlessUser}
isAuthenticated={authenticated}
isPasswordlessUser={passwordlessUser}
avatarUrl={avatarUrl}
onAction={onAction}
displayName={
displayName || (userName ? userName.replace("@", "") : undefined)
}
displayName={displayName || (userName ? userName.replace("@", "") : "")}
/>
{modalState.isOpen && (
{modalState.isOpen && client && (
<SettingsModal
client={client}
defaultTab={defaultSettingsTab}

View File

@@ -98,7 +98,7 @@ export class PosthogAnalytics {
// set true during the constructor if posthog config is present, otherwise false
private static internalInstance: PosthogAnalytics | null = null;
private identificationPromise: Promise<void>;
private identificationPromise?: Promise<void>;
private readonly enabled: boolean = false;
private anonymity = Anonymity.Disabled;
private platformSuperProperties = {};
@@ -255,7 +255,9 @@ export class PosthogAnalytics {
} catch (e) {
// The above could fail due to network requests, but not essential to starting the application,
// so swallow it.
logger.log("Unable to identify user for tracking" + e.toString());
logger.log(
"Unable to identify user for tracking" + (e as Error)?.toString()
);
}
if (analyticsID) {
this.posthog.identify(analyticsID);
@@ -366,7 +368,7 @@ export class PosthogAnalytics {
if (anonymity === Anonymity.Pseudonymous) {
this.setRegistrationType(
window.matrixclient.isGuest() || window.isPasswordlessUser
window.matrixclient.isGuest() || window.passwordlessUser
? RegistrationType.Guest
: RegistrationType.Registered
);

View File

@@ -22,10 +22,10 @@ limitations under the License.
// Array.prototype.findLastIndex
export function findLastIndex<T>(
array: T[],
predicate: (item: T) => boolean
predicate: (item: T, index: number) => boolean
): number | null {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i])) return i;
if (predicate(array[i], i)) return i;
}
return null;
@@ -34,5 +34,11 @@ export function findLastIndex<T>(
/**
* Counts the number of elements in an array that satsify the given predicate.
*/
export const count = <T>(array: T[], predicate: (item: T) => boolean): number =>
array.reduce((acc, item) => (predicate(item) ? acc + 1 : acc), 0);
export const count = <T>(
array: T[],
predicate: (item: T, index: number) => boolean
): number =>
array.reduce(
(acc, item, index) => (predicate(item, index) ? acc + 1 : acc),
0
);

View File

@@ -35,8 +35,8 @@ export const LoginPage: FC = () => {
const { setClient } = useClient();
const login = useInteractiveLogin();
const homeserver = Config.defaultHomeserverUrl(); // TODO: Make this configurable
const usernameRef = useRef<HTMLInputElement>();
const passwordRef = useRef<HTMLInputElement>();
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const history = useHistory();
const location = useLocation();
const [loading, setLoading] = useState(false);
@@ -49,12 +49,27 @@ export const LoginPage: FC = () => {
e.preventDefault();
setLoading(true);
if (!homeserver || !usernameRef.current || !passwordRef.current) {
setError(Error("Login parameters are undefined"));
setLoading(false);
return;
}
login(homeserver, usernameRef.current.value, passwordRef.current.value)
.then(([client, session]) => {
setClient(client, session);
if (!setClient) {
return;
}
if (location.state && location.state.from) {
history.push(location.state.from);
setClient({ client, session });
const locationState = location.state;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (locationState && locationState.from) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
history.push(locationState.from);
} else {
history.push("/");
}

View File

@@ -30,7 +30,7 @@ import { Trans, useTranslation } from "react-i18next";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { useClient } from "../ClientContext";
import { useClientLegacy } from "../ClientContext";
import { useInteractiveRegistration } from "./useInteractiveRegistration";
import styles from "./LoginPage.module.css";
import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
@@ -45,17 +45,17 @@ export const RegisterPage: FC = () => {
const { t } = useTranslation();
usePageTitle(t("Register"));
const { loading, isAuthenticated, isPasswordlessUser, client, setClient } =
useClient();
const confirmPasswordRef = useRef<HTMLInputElement>();
const { loading, authenticated, passwordlessUser, client, setClient } =
useClientLegacy();
const confirmPasswordRef = useRef<HTMLInputElement>(null);
const history = useHistory();
const location = useLocation();
const [registering, setRegistering] = useState(false);
const [error, setError] = useState<Error>();
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { recaptchaKey, register } = useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const onSubmitRegisterForm = useCallback(
@@ -76,10 +76,15 @@ export const RegisterPage: FC = () => {
userName,
password,
userName,
recaptchaResponse
recaptchaResponse,
passwordlessUser
);
if (client && isPasswordlessUser) {
if (!client || !client.groupCallEventHandler || !setClient) {
return;
}
if (passwordlessUser) {
// Migrate the user's rooms
for (const groupCall of client.groupCallEventHandler.groupCalls.values()) {
const roomId = groupCall.room.roomId;
@@ -87,7 +92,11 @@ export const RegisterPage: FC = () => {
try {
await newClient.joinRoom(roomId);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (error.errcode === "M_LIMIT_EXCEEDED") {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await sleep(error.data.retry_after_ms);
await newClient.joinRoom(roomId);
} else {
@@ -98,13 +107,17 @@ export const RegisterPage: FC = () => {
}
}
setClient(newClient, session);
setClient({ client: newClient, session });
PosthogAnalytics.instance.eventSignup.cacheSignupEnd(new Date());
};
submit()
.then(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (location.state?.from) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
history.push(location.state?.from);
} else {
history.push("/");
@@ -120,7 +133,7 @@ export const RegisterPage: FC = () => {
register,
location,
history,
isPasswordlessUser,
passwordlessUser,
reset,
execute,
client,
@@ -137,10 +150,10 @@ export const RegisterPage: FC = () => {
}, [password, passwordConfirmation, t]);
useEffect(() => {
if (!loading && isAuthenticated && !isPasswordlessUser && !registering) {
if (!loading && authenticated && !passwordlessUser && !registering) {
history.push("/");
}
}, [loading, history, isAuthenticated, isPasswordlessUser, registering]);
}, [loading, history, authenticated, passwordlessUser, registering]);
if (loading) {
return <LoadingView />;
@@ -211,7 +224,7 @@ export const RegisterPage: FC = () => {
apply.
<br />
By clicking "Register", you agree to our{" "}
<Link href={privacyPolicyUrl}>
<Link href={Config.get().eula}>
End User Licensing Agreement (EULA)
</Link>
</Trans>

View File

@@ -41,8 +41,10 @@ export const useInteractiveLogin = () =>
},
password,
}),
stateUpdated: null,
requestEmailToken: null,
stateUpdated: (...args) => {},
requestEmailToken: (...args): Promise<{ sid: string }> => {
return Promise.resolve({ sid: "" });
},
});
// XXX: This claims to return an IAuthData which contains none of these

View File

@@ -22,29 +22,33 @@ import { initClient } from "../matrix-utils";
import { Session } from "../ClientContext";
import { Config } from "../config/Config";
export const useInteractiveRegistration = (): [
string,
string,
(
export const useInteractiveRegistration = (): {
privacyPolicyUrl?: string;
recaptchaKey?: string;
register: (
username: string,
password: string,
displayName: string,
recaptchaResponse: string,
passwordlessUser?: boolean
) => Promise<[MatrixClient, Session]>
] => {
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string>();
const [recaptchaKey, setRecaptchaKey] = useState<string>();
passwordlessUser: boolean
) => Promise<[MatrixClient, Session]>;
} => {
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
undefined
);
const [recaptchaKey, setRecaptchaKey] = useState<string | undefined>(
undefined
);
const authClient = useRef<MatrixClient>();
if (!authClient.current) {
authClient.current = createClient({
baseUrl: Config.defaultHomeserverUrl(),
baseUrl: Config.defaultHomeserverUrl()!,
});
}
useEffect(() => {
authClient.current.registerRequest({}).catch((error) => {
authClient.current!.registerRequest({}).catch((error) => {
setPrivacyPolicyUrl(
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url
);
@@ -58,12 +62,12 @@ export const useInteractiveRegistration = (): [
password: string,
displayName: string,
recaptchaResponse: string,
passwordlessUser?: boolean
passwordlessUser: boolean
): Promise<[MatrixClient, Session]> => {
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient.current,
matrixClient: authClient.current!,
doRequest: (auth) =>
authClient.current.registerRequest({
authClient.current!.registerRequest({
username,
password,
auth: auth || undefined,
@@ -84,7 +88,9 @@ export const useInteractiveRegistration = (): [
});
}
},
requestEmailToken: null,
requestEmailToken: (...args) => {
return Promise.resolve({ sid: "dummy" });
},
});
// XXX: This claims to return an IAuthData which contains none of these
@@ -95,7 +101,7 @@ export const useInteractiveRegistration = (): [
const client = await initClient(
{
baseUrl: Config.defaultHomeserverUrl(),
baseUrl: Config.defaultHomeserverUrl()!,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
@@ -117,7 +123,7 @@ export const useInteractiveRegistration = (): [
session.tempPassword = password;
}
const user = client.getUser(client.getUserId());
const user = client.getUser(client.getUserId()!)!;
user.setRawDisplayName(displayName);
user.setDisplayName(displayName);
@@ -126,5 +132,5 @@ export const useInteractiveRegistration = (): [
[]
);
return [privacyPolicyUrl, recaptchaKey, register];
return { privacyPolicyUrl, recaptchaKey, register };
};

View File

@@ -34,7 +34,7 @@ interface RecaptchaPromiseRef {
reject: (error: Error) => void;
}
export const useRecaptcha = (sitekey: string) => {
export const useRecaptcha = (sitekey?: string) => {
const { t } = useTranslation();
const [recaptchaId] = useState(() => randomString(16));
const promiseRef = useRef<RecaptchaPromiseRef>();
@@ -68,9 +68,9 @@ export const useRecaptcha = (sitekey: string) => {
}
}, [recaptchaId, sitekey]);
const execute = useCallback(() => {
const execute = useCallback((): Promise<string> => {
if (!sitekey) {
return Promise.resolve(null);
return Promise.resolve("");
}
if (!window.grecaptcha) {

View File

@@ -23,19 +23,23 @@ import { generateRandomName } from "../auth/generateRandomName";
import { useRecaptcha } from "../auth/useRecaptcha";
interface UseRegisterPasswordlessUserType {
privacyPolicyUrl: string;
privacyPolicyUrl?: string;
registerPasswordlessUser: (displayName: string) => Promise<void>;
recaptchaId: string;
recaptchaId?: string;
}
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
const { setClient } = useClient();
const [privacyPolicyUrl, recaptchaKey, register] =
const { privacyPolicyUrl, recaptchaKey, register } =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const registerPasswordlessUser = useCallback(
async (displayName: string) => {
if (!setClient) {
throw new Error("No client context");
}
try {
const recaptchaResponse = await execute();
const userName = generateRandomName();
@@ -46,7 +50,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
recaptchaResponse,
true
);
setClient(client, session);
setClient({ client, session });
} catch (e) {
reset();
throw e;

View File

@@ -61,6 +61,10 @@ limitations under the License.
outline: auto;
}
.toolbarButton:disabled {
opacity: 0.55;
}
.toolbarButton,
.toolbarButtonSecondary {
width: 50px;

View File

@@ -77,6 +77,7 @@ interface Props {
children: Element[];
onPress: (e: PressEvent) => void;
onPressStart: (e: PressEvent) => void;
disabled: boolean;
// TODO: add all props for <Button>
[index: string]: unknown;
}

View File

@@ -46,7 +46,7 @@ export function LinkButton({
<Link
className={classNames(
variantToClassName[variant || "secondary"],
sizeToClassName[size],
size ? sizeToClassName[size] : [],
className
)}
to={to}

View File

@@ -45,11 +45,11 @@ export class Config {
// Convenience accessors
public static defaultHomeserverUrl(): string | undefined {
return Config.get().default_server_config["m.homeserver"].base_url;
return Config.get().default_server_config?.["m.homeserver"].base_url;
}
public static defaultServerName(): string | undefined {
return Config.get().default_server_config["m.homeserver"].server_name;
return Config.get().default_server_config?.["m.homeserver"].server_name;
}
public config?: ResolvedConfigOptions;

View File

@@ -66,6 +66,11 @@ export interface ConfigOptions {
features?: {
feature_group_calls_without_video_and_audio: boolean;
};
/**
* A link to the end-user license agreement (EULA)
*/
eula: string;
}
// Overrides members from ConfigOptions that are always provided by the
@@ -86,4 +91,5 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
server_name: "localhost",
},
},
eula: "https://static.element.io/legal/online-EULA.pdf",
};

View File

@@ -42,6 +42,12 @@ interface Props {
export const CallTypeDropdown: FC<Props> = ({ callType, setCallType }) => {
const { t } = useTranslation();
const onAction = (key: React.Key) => {
setCallType(key.toString() as CallType);
};
const onClose = () => {};
return (
<PopoverMenuTrigger placement="bottom">
<Button variant="dropdown" className={commonStyles.headline}>
@@ -52,7 +58,12 @@ export const CallTypeDropdown: FC<Props> = ({ callType, setCallType }) => {
</Headline>
</Button>
{(props: JSX.IntrinsicAttributes) => (
<Menu {...props} label={t("Call type menu")} onAction={setCallType}>
<Menu
{...props}
label={t("Call type menu")}
onAction={onAction}
onClose={onClose}
>
<Item key={CallType.Video} textValue={t("Video call")}>
<VideoIcon />
<span>{t("Video call")}</span>

View File

@@ -16,7 +16,7 @@ limitations under the License.
import { useTranslation } from "react-i18next";
import { useClient } from "../ClientContext";
import { useClientState } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
import { UnauthenticatedView } from "./UnauthenticatedView";
import { RegisteredView } from "./RegisteredView";
@@ -26,16 +26,18 @@ export function HomePage() {
const { t } = useTranslation();
usePageTitle(t("Home"));
const { isAuthenticated, isPasswordlessUser, loading, error, client } =
useClient();
const clientState = useClientState();
if (loading) {
if (!clientState) {
return <LoadingView />;
} else if (error) {
return <ErrorView error={error} />;
} else if (clientState.state === "error") {
return <ErrorView error={clientState.error} />;
} else {
return isAuthenticated ? (
<RegisteredView isPasswordlessUser={isPasswordlessUser} client={client} />
return clientState.authenticated ? (
<RegisteredView
isPasswordlessUser={clientState.authenticated.isPasswordlessUser}
client={clientState.authenticated.client}
/>
) : (
<UnauthenticatedView />
);

View File

@@ -39,6 +39,7 @@ import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import { useOptInAnalytics } from "../settings/useSetting";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { E2EEBanner } from "../E2EEBanner";
interface Props {
client: MatrixClient;
@@ -146,6 +147,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
<AnalyticsNotice />
</Caption>
)}
<E2EEBanner />
{error && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage error={error} />

View File

@@ -41,6 +41,8 @@ import commonStyles from "./common.module.css";
import { generateRandomName } from "../auth/generateRandomName";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { useOptInAnalytics } from "../settings/useSetting";
import { Config } from "../config/Config";
import { E2EEBanner } from "../E2EEBanner";
export const UnauthenticatedView: FC = () => {
const { setClient } = useClient();
@@ -48,8 +50,7 @@ export const UnauthenticatedView: FC = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics();
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { recaptchaKey, register } = useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const { modalState, modalProps } = useModalTriggerState();
@@ -82,9 +83,15 @@ export const UnauthenticatedView: FC = () => {
try {
[roomAlias] = await createRoom(client, roomName, ptt);
} catch (error) {
if (!setClient) {
throw error;
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (error.errcode === "M_ROOM_IN_USE") {
setOnFinished(() => {
setClient(client, session);
setClient({ client, session });
const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
history.push(`/${aliasLocalpart}`);
});
@@ -98,7 +105,11 @@ export const UnauthenticatedView: FC = () => {
}
// Only consider the registration successful if we managed to create the room, too
setClient(client, session);
if (!setClient) {
throw new Error("setClient is undefined");
}
setClient({ client, session });
history.push(`/${roomAlias.substring(1).split(":")[0]}`);
}
@@ -164,11 +175,12 @@ export const UnauthenticatedView: FC = () => {
<Caption className={styles.notice}>
<Trans>
By clicking "Go", you agree to our{" "}
<Link href={privacyPolicyUrl}>
<Link href={Config.get().eula}>
End User Licensing Agreement (EULA)
</Link>
</Trans>
</Caption>
<E2EEBanner />
{error && (
<FieldRow>
<ErrorMessage error={error} />
@@ -201,7 +213,7 @@ export const UnauthenticatedView: FC = () => {
</Body>
</footer>
</div>
{modalState.isOpen && (
{modalState.isOpen && onFinished && (
<JoinExistingCallModal onJoin={onFinished} {...modalProps} />
)}
</>

View File

@@ -42,7 +42,7 @@ function getLastTs(client: MatrixClient, r: Room) {
return ts;
}
const myUserId = client.getUserId();
const myUserId = client.getUserId()!;
if (r.getMyMembership() !== "join") {
const membershipEvent = r.currentState.getStateEvents(
@@ -83,23 +83,28 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
useEffect(() => {
function updateRooms() {
if (!client.groupCallEventHandler) {
return;
}
const groupCalls = client.groupCallEventHandler.groupCalls.values();
const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room);
const filteredRooms = rooms.filter((r) => r.getCanonicalAlias()); // We don't display rooms without an alias
const sortedRooms = sortRooms(client, filteredRooms);
const items: GroupCallRoom[] = sortedRooms.map((room) => {
const groupCall = client.getGroupCallForRoom(room.roomId);
const items = sortedRooms.map((room) => {
const groupCall = client.getGroupCallForRoom(room.roomId)!;
return {
roomAlias: room.getCanonicalAlias(),
roomName: room.name,
avatarUrl: room.getMxcAvatarUrl(),
avatarUrl: room.getMxcAvatarUrl()!,
room,
groupCall,
participants: [...groupCall.participants.keys()],
participants: [...groupCall!.participants.keys()],
};
});
setRooms(items);
setRooms(items as GroupCallRoom[]);
}
updateRooms();

4
src/icons/LockOff.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.00003 14.6665C3.63336 14.6665 3.31947 14.5359 3.05836 14.2748C2.79725 14.0137 2.6667 13.6998 2.6667 13.3332V6.6665C2.6667 6.29984 2.79725 5.98595 3.05836 5.72484C3.19921 5.58399 3.35541 5.48113 3.52697 5.41626L0.888621 2.77791C0.628272 2.51756 0.628272 2.09545 0.888622 1.8351C1.14897 1.57475 1.57108 1.57475 1.83143 1.8351L4.6667 4.67037V4.66267L13.3334 13.3293V13.3332L13.3334 13.337L14.1648 14.1685C14.4251 14.4288 14.4251 14.8509 14.1648 15.1113C13.9044 15.3716 13.4823 15.3716 13.222 15.1113L12.6247 14.514C12.437 14.6157 12.2288 14.6665 12 14.6665H4.00003Z" fill="#808994"/>
<path d="M13.3334 11.4437V6.6665C13.3334 6.29984 13.2028 5.98595 12.9417 5.72484C12.6806 5.46373 12.3667 5.33317 12 5.33317H11.3334V3.99984C11.3334 3.07762 11.0084 2.2915 10.3584 1.6415C9.70836 0.991504 8.92225 0.666504 8.00003 0.666504C7.07781 0.666504 6.2917 0.991504 5.6417 1.6415C5.25683 2.02637 4.9859 2.45896 4.82892 2.93927L6.00003 4.11038V3.99984C6.00003 3.44428 6.19447 2.97206 6.58336 2.58317C6.97225 2.19428 7.44447 1.99984 8.00003 1.99984C8.55559 1.99984 9.02781 2.19428 9.4167 2.58317C9.80558 2.97206 10 3.44428 10 3.99984V5.33317H7.22282L13.3334 11.4437Z" fill="#808994"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -52,6 +52,7 @@ limitations under the License.
--background: #15191e;
--background-85: rgba(23, 25, 28, 0.85);
--bgColor3: #444; /* This isn't found anywhere in the designs or Compound */
--subtle-primary: #26282d;
}
@font-face {

View File

@@ -133,6 +133,10 @@ export class Initializer {
"--background-85",
import.meta.env.VITE_THEME_BACKGROUND_85 as string
);
style.setProperty(
"--subtle-primary",
import.meta.env.VITE_THEME_SUBTLE_PRIMARY as string
);
}
// Custom fonts
@@ -225,5 +229,6 @@ export class Initializer {
resolve();
}
}
private initPromise: Promise<void> | null;
private initPromise?: Promise<void>;
}

View File

@@ -15,10 +15,14 @@ limitations under the License.
*/
import { useObjectRef } from "@react-aria/utils";
import { AllHTMLAttributes, ChangeEvent, useEffect } from "react";
import { useCallback } from "react";
import { useState } from "react";
import { forwardRef } from "react";
import {
AllHTMLAttributes,
useEffect,
useCallback,
useState,
forwardRef,
ChangeEvent,
} from "react";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
@@ -43,7 +47,7 @@ export const AvatarInputField = forwardRef<HTMLInputElement, Props>(
const { t } = useTranslation();
const [removed, setRemoved] = useState(false);
const [objUrl, setObjUrl] = useState<string>(null);
const [objUrl, setObjUrl] = useState<string | undefined>(undefined);
const fileInputRef = useObjectRef(ref);
@@ -52,11 +56,11 @@ export const AvatarInputField = forwardRef<HTMLInputElement, Props>(
const onChange = (e: Event) => {
const inputEvent = e as unknown as ChangeEvent<HTMLInputElement>;
if (inputEvent.target.files.length > 0) {
if (inputEvent.target.files && inputEvent.target.files.length > 0) {
setObjUrl(URL.createObjectURL(inputEvent.target.files[0]));
setRemoved(false);
} else {
setObjUrl(null);
setObjUrl(undefined);
}
};
@@ -77,7 +81,7 @@ export const AvatarInputField = forwardRef<HTMLInputElement, Props>(
<div className={styles.avatarContainer}>
<Avatar
size={Size.XL}
src={removed ? null : objUrl || avatarUrl}
src={removed ? undefined : objUrl || avatarUrl}
fallback={displayName.slice(0, 1).toUpperCase()}
/>
<input

View File

@@ -82,7 +82,7 @@ interface InputFieldProps {
defaultValue?: string;
placeholder?: string;
defaultChecked?: boolean;
onChange?: (event: ChangeEvent) => void;
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
}
export const InputField = forwardRef<
@@ -119,6 +119,8 @@ export const InputField = forwardRef<
>
{prefix && <span>{prefix}</span>}
{type === "textarea" ? (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<textarea
id={id}
ref={ref as ForwardedRef<HTMLTextAreaElement>}

View File

@@ -34,7 +34,7 @@ export function SelectInput(props: Props): JSX.Element {
const { t } = useTranslation();
const state = useSelectState(props);
const ref = useRef();
const ref = useRef(null);
const { labelProps, triggerProps, valueProps, menuProps } = useSelect(
props,
state,

View File

@@ -22,6 +22,7 @@ import {
useState,
} from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { GroupCall } from "matrix-js-sdk";
import {
OpenIDClientParts,
@@ -32,21 +33,16 @@ import { ErrorView, LoadingView } from "../FullScreenView";
interface Props {
client: OpenIDClientParts;
livekitServiceURL: string;
groupCall: GroupCall;
roomName: string;
children: ReactNode;
}
const SFUConfigContext = createContext<SFUConfig>(undefined);
const SFUConfigContext = createContext<SFUConfig | undefined>(undefined);
export const useSFUConfig = () => useContext(SFUConfigContext);
export function OpenIDLoader({
client,
livekitServiceURL,
roomName,
children,
}: Props) {
export function OpenIDLoader({ client, groupCall, roomName, children }: Props) {
const [state, setState] = useState<
SFUConfigLoading | SFUConfigLoaded | SFUConfigFailed
>({ kind: "loading" });
@@ -56,16 +52,16 @@ export function OpenIDLoader({
try {
const result = await getSFUConfigWithOpenID(
client,
livekitServiceURL,
groupCall,
roomName
);
setState({ kind: "loaded", sfuConfig: result });
} catch (e) {
logger.error("Failed to fetch SFU config: ", e);
setState({ kind: "failed", error: e });
setState({ kind: "failed", error: e as Error });
}
})();
}, [client, livekitServiceURL, roomName]);
}, [client, groupCall, roomName]);
switch (state.kind) {
case "loading":

View File

@@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk";
import { GroupCall, IOpenIDToken, MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/src/logger";
import { Config } from "../config/Config";
export interface SFUConfig {
url: string;
jwt: string;
@@ -30,25 +32,84 @@ export type OpenIDClientParts = Pick<
export async function getSFUConfigWithOpenID(
client: OpenIDClientParts,
livekitServiceURL: string,
groupCall: GroupCall,
roomName: string
): Promise<SFUConfig> {
const openIdToken = await client.getOpenIdToken();
logger.debug("Got openID token", openIdToken);
const res = await fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
room: roomName,
openid_token: openIdToken,
device_id: client.getDeviceId(),
}),
});
if (!res.ok) {
throw new Error("SFO Config fetch failed with status code " + res.status);
// if the call has a livekit service URL, try it.
if (groupCall.livekitServiceURL) {
try {
logger.info(`Trying to get JWT from ${groupCall.livekitServiceURL}...`);
const sfuConfig = await getLiveKitJWT(
client,
groupCall.livekitServiceURL,
roomName,
openIdToken
);
return sfuConfig;
} catch (e) {
logger.warn(
`Failed to get JWT from group call's configured URL of ${groupCall.livekitServiceURL}.`,
e
);
}
}
// otherwise, try our configured one and, if it works, update the call's service URL in the state event
// NB. This wuill update it for everyone so we may end up with multiple clients updating this when they
// join at similar times, but we don't have a huge number of options here.
const urlFromConf = Config.get().livekit!.livekit_service_url;
logger.info(`Trying livekit service URL from our config: ${urlFromConf}...`);
try {
const sfuConfig = await getLiveKitJWT(
client,
urlFromConf,
roomName,
openIdToken
);
logger.info(`Updating call livekit service URL with: ${urlFromConf}...`);
try {
await groupCall.updateLivekitServiceURL(urlFromConf);
} catch (e) {
logger.warn(
`Failed to update call livekit service URL: continuing anyway.`
);
}
return sfuConfig;
} catch (e) {
logger.error("Failed to get JWT from URL defined in Config.", e);
throw e;
}
}
async function getLiveKitJWT(
client: OpenIDClientParts,
livekitServiceURL: string,
roomName: string,
openIDToken: IOpenIDToken
): Promise<SFUConfig> {
try {
const res = await fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
room: roomName,
openid_token: openIDToken,
device_id: client.getDeviceId(),
}),
});
if (!res.ok) {
throw new Error("SFU Config fetch failed with status code " + res.status);
}
return await res.json();
} catch (e) {
throw new Error("SFU Config fetch failed with exception " + e);
}
return await res.json();
}

View File

@@ -14,16 +14,12 @@ const defaultLiveKitPublishOptions: TrackPublishDefaults = {
red: true,
forceStereo: false,
simulcast: true,
videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h216] as VideoPreset[],
videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360] as VideoPreset[],
screenShareEncoding: ScreenSharePresets.h1080fps30.encoding,
screenShareSimulcastLayers: [
new VideoPreset(1920, 1080, 1_500_000, 5, "medium"),
ScreenSharePresets.h1080fps15,
] as VideoPreset[],
stopMicTrackOnMute: false,
videoCodec: "vp8",
videoEncoding: VideoPresets.h360.encoding,
backupCodec: { codec: "vp8", encoding: VideoPresets.h360.encoding },
videoEncoding: VideoPresets.h720.encoding,
backupCodec: { codec: "vp8", encoding: VideoPresets.h720.encoding },
} as const;
export const defaultLiveKitOptions: RoomOptions = {
@@ -35,7 +31,7 @@ export const defaultLiveKitOptions: RoomOptions = {
// capture settings
videoCaptureDefaults: {
resolution: VideoPresets.h360.resolution,
resolution: VideoPresets.h720.resolution,
},
// publish settings

View File

@@ -17,7 +17,7 @@ export type DeviceChoices = {
export function useLiveKit(
userChoices: UserChoices,
sfuConfig: SFUConfig
sfuConfig?: SFUConfig
): Room | undefined {
const roomOptions = useMemo((): RoomOptions => {
const options = defaultLiveKitOptions;
@@ -33,8 +33,8 @@ export function useLiveKit(
}, [userChoices.video, userChoices.audio]);
const { room } = useLiveKitRoom({
token: sfuConfig.jwt,
serverUrl: sfuConfig.url,
token: sfuConfig?.jwt,
serverUrl: sfuConfig?.url,
audio: userChoices.audio?.enabled ?? false,
video: userChoices.video?.enabled ?? false,
options: roomOptions,

View File

@@ -1,5 +1,5 @@
import { useMediaDeviceSelect } from "@livekit/components-react";
import { Room } from "livekit-client";
import { LocalAudioTrack, LocalVideoTrack, Room } from "livekit-client";
import { useEffect } from "react";
import { useDefaultDevices } from "../settings/useSetting";
@@ -17,12 +17,21 @@ export type MediaDevicesState = {
};
// if a room is passed this only affects the device selection inside a call. Without room it changes what we see in the lobby
export function useMediaDevices(room?: Room): MediaDevicesState {
export function useMediaDevicesSwitcher(
room?: Room,
tracks?: { videoTrack?: LocalVideoTrack; audioTrack?: LocalAudioTrack },
requestPermissions = true
): MediaDevicesState {
const {
devices: videoDevices,
activeDeviceId: activeVideoDevice,
setActiveMediaDevice: setActiveVideoDevice,
} = useMediaDeviceSelect({ kind: "videoinput", room });
} = useMediaDeviceSelect({
kind: "videoinput",
room,
track: tracks?.videoTrack,
requestPermissions,
});
const {
devices: audioDevices,
@@ -31,6 +40,8 @@ export function useMediaDevices(room?: Room): MediaDevicesState {
} = useMediaDeviceSelect({
kind: "audioinput",
room,
track: tracks?.audioTrack,
requestPermissions,
});
const {

View File

@@ -19,8 +19,7 @@ import { MemoryStore } from "matrix-js-sdk/src/store/memory";
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
import { LocalStorageCryptoStore } from "matrix-js-sdk/src/crypto/store/localStorage-crypto-store";
import { MemoryCryptoStore } from "matrix-js-sdk/src/crypto/store/memory-crypto-store";
import { createClient } from "matrix-js-sdk/src/matrix";
import { ICreateClientOpts } from "matrix-js-sdk/src/matrix";
import { createClient, ICreateClientOpts } from "matrix-js-sdk/src/matrix";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
@@ -57,8 +56,8 @@ function waitForSync(client: MatrixClient) {
return new Promise<void>((resolve, reject) => {
const onSync = (
state: SyncState,
_old: SyncState,
data: ISyncStateData
_old: SyncState | null,
data?: ISyncStateData
) => {
if (state === "PREPARED") {
resolve();
@@ -87,7 +86,7 @@ export async function initClient(
): Promise<MatrixClient> {
await loadOlm();
let indexedDB: IDBFactory;
let indexedDB: IDBFactory | undefined;
try {
indexedDB = window.indexedDB;
} catch (e) {}
@@ -247,7 +246,7 @@ export function sanitiseRoomNameInput(input: string): string {
*/
export function roomNameFromRoomId(roomId: string): string {
return roomId
.match(/([^:]+):.*$/)[1]
.match(/([^:]+):.*$/)![1]
.substring(1)
.split("-")
.map((part) =>
@@ -262,7 +261,7 @@ export function isLocalRoomId(roomId: string, client: MatrixClient): boolean {
return false;
}
const parts = roomId.match(/[^:]+:(.*)$/);
const parts = roomId.match(/[^:]+:(.*)$/)!;
if (parts.length < 2) {
return false;
@@ -302,7 +301,7 @@ export async function createRoom(
"org.matrix.msc3401.call.member": 0,
},
users: {
[client.getUserId()]: 100,
[client.getUserId()!]: 100,
},
},
});

View File

@@ -61,46 +61,46 @@ export class OTelCall {
}
public dispose() {
this.call.peerConn.removeEventListener(
this.call.peerConn?.removeEventListener(
"connectionstatechange",
this.onCallConnectionStateChanged
);
this.call.peerConn.removeEventListener(
this.call.peerConn?.removeEventListener(
"signalingstatechange",
this.onCallSignalingStateChanged
);
this.call.peerConn.removeEventListener(
this.call.peerConn?.removeEventListener(
"iceconnectionstatechange",
this.onIceConnectionStateChanged
);
this.call.peerConn.removeEventListener(
this.call.peerConn?.removeEventListener(
"icegatheringstatechange",
this.onIceGatheringStateChanged
);
this.call.peerConn.removeEventListener(
this.call.peerConn?.removeEventListener(
"icecandidateerror",
this.onIceCandidateError
);
}
private addCallPeerConnListeners = (): void => {
this.call.peerConn.addEventListener(
this.call.peerConn?.addEventListener(
"connectionstatechange",
this.onCallConnectionStateChanged
);
this.call.peerConn.addEventListener(
this.call.peerConn?.addEventListener(
"signalingstatechange",
this.onCallSignalingStateChanged
);
this.call.peerConn.addEventListener(
this.call.peerConn?.addEventListener(
"iceconnectionstatechange",
this.onIceConnectionStateChanged
);
this.call.peerConn.addEventListener(
this.call.peerConn?.addEventListener(
"icegatheringstatechange",
this.onIceGatheringStateChanged
);
this.call.peerConn.addEventListener(
this.call.peerConn?.addEventListener(
"icecandidateerror",
this.onIceCandidateError
);
@@ -108,25 +108,25 @@ export class OTelCall {
public onCallConnectionStateChanged = (): void => {
this.span.addEvent("matrix.call.callConnectionStateChange", {
callConnectionState: this.call.peerConn.connectionState,
callConnectionState: this.call.peerConn?.connectionState,
});
};
public onCallSignalingStateChanged = (): void => {
this.span.addEvent("matrix.call.callSignalingStateChange", {
callSignalingState: this.call.peerConn.signalingState,
callSignalingState: this.call.peerConn?.signalingState,
});
};
public onIceConnectionStateChanged = (): void => {
this.span.addEvent("matrix.call.iceConnectionStateChange", {
iceConnectionState: this.call.peerConn.iceConnectionState,
iceConnectionState: this.call.peerConn?.iceConnectionState,
});
};
public onIceGatheringStateChanged = (): void => {
this.span.addEvent("matrix.call.iceGatheringStateChange", {
iceGatheringState: this.call.peerConn.iceGatheringState,
iceGatheringState: this.call.peerConn?.iceGatheringState,
});
};

View File

@@ -172,7 +172,7 @@ export class OTelGroupCallMembership {
if (
!userCalls ||
!userCalls.has(callTrackingInfo.deviceId) ||
userCalls.get(callTrackingInfo.deviceId).callId !==
userCalls.get(callTrackingInfo.deviceId)?.callId !==
callTrackingInfo.call.callId
) {
callTrackingInfo.end();
@@ -420,7 +420,7 @@ export class OTelGroupCallMembership {
ctx
);
span.setAttribute("matrix.callId", callId);
span.setAttribute("matrix.callId", callId ?? "unknown");
span.setAttribute(
"matrix.opponentMemberId",
report.opponentMemberId ? report.opponentMemberId : "unknown"

View File

@@ -23,7 +23,6 @@ import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions"
import { logger } from "matrix-js-sdk/src/logger";
import { PosthogSpanProcessor } from "../analytics/PosthogSpanProcessor";
import { Anonymity } from "../analytics/PosthogAnalytics";
import { Config } from "../config/Config";
import { RageshakeSpanProcessor } from "../analytics/RageshakeSpanProcessor";
@@ -34,8 +33,7 @@ let sharedInstance: ElementCallOpenTelemetry;
export class ElementCallOpenTelemetry {
private _provider: WebTracerProvider;
private _tracer: Tracer;
private _anonymity: Anonymity;
private otlpExporter: OTLPTraceExporter;
private otlpExporter?: OTLPTraceExporter;
public readonly rageshakeProcessor?: RageshakeSpanProcessor;
static globalInit(): void {
@@ -100,7 +98,7 @@ export class ElementCallOpenTelemetry {
}
public dispose(): void {
opentelemetry.trace.setGlobalTracerProvider(null);
opentelemetry.trace.disable();
this._provider?.shutdown();
}
@@ -115,8 +113,4 @@ export class ElementCallOpenTelemetry {
public get provider(): WebTracerProvider {
return this._provider;
}
public get anonymity(): Anonymity {
return this._anonymity;
}
}

View File

@@ -46,7 +46,7 @@ export const PopoverMenuTrigger = forwardRef<
buttonRef
);
const popoverRef = useRef();
const popoverRef = useRef(null);
const { overlayProps } = useOverlayPosition({
targetRef: buttonRef,

View File

@@ -21,10 +21,10 @@ import { FileType } from "matrix-js-sdk/src/http-api";
import { useState, useCallback, useEffect } from "react";
interface ProfileLoadState {
success?: boolean;
loading?: boolean;
displayName: string;
avatarUrl: string;
success: boolean;
loading: boolean;
displayName?: string;
avatarUrl?: string;
error?: Error;
}
@@ -38,23 +38,26 @@ type ProfileSaveCallback = ({
removeAvatar: boolean;
}) => Promise<void>;
export function useProfile(client: MatrixClient) {
const [{ loading, displayName, avatarUrl, error, success }, setState] =
export function useProfile(client: MatrixClient | undefined) {
const [{ success, loading, displayName, avatarUrl, error }, setState] =
useState<ProfileLoadState>(() => {
const user = client?.getUser(client.getUserId());
let user: User | undefined = undefined;
if (client) {
user = client.getUser(client.getUserId()!) ?? undefined;
}
return {
success: false,
loading: false,
displayName: user?.rawDisplayName,
avatarUrl: user?.avatarUrl,
error: null,
error: undefined,
};
});
useEffect(() => {
const onChangeUser = (
_event: MatrixEvent,
_event: MatrixEvent | undefined,
{ displayName, avatarUrl }: User
) => {
setState({
@@ -62,17 +65,16 @@ export function useProfile(client: MatrixClient) {
loading: false,
displayName,
avatarUrl,
error: null,
error: undefined,
});
};
let user: User;
let user: User | null;
if (client) {
const userId = client.getUserId();
const userId = client.getUserId()!;
user = client.getUser(userId);
user.on(UserEvent.DisplayName, onChangeUser);
user.on(UserEvent.AvatarUrl, onChangeUser);
user?.on(UserEvent.DisplayName, onChangeUser);
user?.on(UserEvent.AvatarUrl, onChangeUser);
}
return () => {
@@ -89,7 +91,7 @@ export function useProfile(client: MatrixClient) {
setState((prev) => ({
...prev,
loading: true,
error: null,
error: undefined,
success: false,
}));
@@ -110,7 +112,9 @@ export function useProfile(client: MatrixClient) {
setState((prev) => ({
...prev,
displayName,
avatarUrl: removeAvatar ? null : mxcAvatarUrl ?? prev.avatarUrl,
avatarUrl: removeAvatar
? undefined
: mxcAvatarUrl ?? prev.avatarUrl,
loading: false,
success: true,
}));

View File

@@ -38,6 +38,15 @@ export function GridLayoutMenu({ layout, setLayout }: Props) {
const { t } = useTranslation();
const tooltip = useCallback(() => t("Change layout"), [t]);
const onAction = useCallback(
(key: React.Key) => {
setLayout(key.toString() as Layout);
},
[setLayout]
);
const onClose = useCallback(() => {}, []);
return (
<PopoverMenuTrigger placement="bottom right">
<TooltipTrigger tooltip={tooltip}>
@@ -46,7 +55,12 @@ export function GridLayoutMenu({ layout, setLayout }: Props) {
</Button>
</TooltipTrigger>
{(props: JSX.IntrinsicAttributes) => (
<Menu {...props} label={t("Grid layout menu")} onAction={setLayout}>
<Menu
{...props}
label={t("Grid layout menu")}
onAction={onAction}
onClose={onClose}
>
<Item key="freedom" textValue={t("Freedom")}>
<FreedomIcon />
<span>Freedom</span>

View File

@@ -1,3 +1,6 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
/*
Copyright 2022 New Vector Ltd
@@ -70,7 +73,7 @@ const defaultCollapsedFields = [
];
function shouldCollapse({ name }: CollapsedFieldProps) {
return defaultCollapsedFields.includes(name);
return name ? defaultCollapsedFields.includes(name) : false;
}
function getUserName(userId: string) {
@@ -196,7 +199,7 @@ export function SequenceDiagramViewer({
onSelectUserId,
events,
}: SequenceDiagramViewerProps) {
const mermaidElRef = useRef<HTMLDivElement>();
const mermaidElRef = useRef<HTMLDivElement>(null);
useEffect(() => {
mermaid.initialize({
@@ -217,6 +220,7 @@ export function SequenceDiagramViewer({
`;
mermaid.mermaidAPI.render("mermaid", graphDefinition, (svgCode: string) => {
if (!mermaidElRef.current) return;
mermaidElRef.current.innerHTML = svgCode;
});
}, [events, localUserId, selectedUserId]);
@@ -228,7 +232,7 @@ export function SequenceDiagramViewer({
className={styles.selectInput}
label="Remote User"
selectedKey={selectedUserId}
onSelectionChange={onSelectUserId}
onSelectionChange={(key) => onSelectUserId(key.toString())}
>
{remoteUserIds.map((userId) => (
<Item key={userId}>{userId}</Item>
@@ -498,7 +502,7 @@ export function GroupCallInspector({
return (
<Resizable
enable={{ top: true }}
defaultSize={{ height: 200, width: undefined }}
defaultSize={{ height: 200, width: 0 }}
className={styles.inspector}
>
<div className={styles.toolbar}>
@@ -507,15 +511,19 @@ export function GroupCallInspector({
</button>
<button onClick={() => setCurrentTab("inspector")}>Inspector</button>
</div>
{currentTab === "sequence-diagrams" && (
<SequenceDiagramViewer
localUserId={state.localUserId}
selectedUserId={selectedUserId}
onSelectUserId={setSelectedUserId}
remoteUserIds={state.remoteUserIds}
events={state.eventsByUserId[selectedUserId]}
/>
)}
{currentTab === "sequence-diagrams" &&
state.localUserId &&
selectedUserId &&
state.eventsByUserId &&
state.remoteUserIds && (
<SequenceDiagramViewer
localUserId={state.localUserId}
selectedUserId={selectedUserId}
onSelectUserId={setSelectedUserId}
remoteUserIds={state.remoteUserIds}
events={state.eventsByUserId[selectedUserId]}
/>
)}
{currentTab === "inspector" && (
<ReactJson
theme="monokai"

View File

@@ -21,7 +21,6 @@ import { useTranslation } from "react-i18next";
import { useLoadGroupCall } from "./useLoadGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
import { usePageTitle } from "../usePageTitle";
interface Props {
client: MatrixClient;
@@ -39,26 +38,23 @@ export function GroupCallLoader({
createPtt,
}: Props): JSX.Element {
const { t } = useTranslation();
const { loading, error, groupCall } = useLoadGroupCall(
const groupCallState = useLoadGroupCall(
client,
roomIdOrAlias,
viaServers,
createPtt
);
usePageTitle(groupCall ? groupCall.room.name : t("Loading…"));
if (loading) {
return (
<FullScreenView>
<h1>{t("Loading…")}</h1>
</FullScreenView>
);
switch (groupCallState.kind) {
case "loading":
return (
<FullScreenView>
<h1>{t("Loading…")}</h1>
</FullScreenView>
);
case "loaded":
return <>{children(groupCallState.groupCall)}</>;
case "failed":
return <ErrorView error={groupCallState.error} />;
}
if (error) {
return <ErrorView error={error} />;
}
return <>{children(groupCall)}</>;
}

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useHistory } from "react-router-dom";
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client";
@@ -49,7 +49,6 @@ interface Props {
isEmbedded: boolean;
preload: boolean;
hideHeader: boolean;
roomIdOrAlias: string;
groupCall: GroupCall;
}
@@ -59,7 +58,6 @@ export function GroupCallView({
isEmbedded,
preload,
hideHeader,
roomIdOrAlias,
groupCall,
}: Props) {
const {
@@ -82,13 +80,14 @@ export function GroupCallView({
}, [groupCall]);
const { displayName, avatarUrl } = useProfile(client);
const matrixInfo: MatrixInfo = {
displayName,
avatarUrl,
roomName: groupCall.room.name,
roomIdOrAlias,
};
const matrixInfo = useMemo((): MatrixInfo => {
return {
displayName: displayName!,
avatarUrl: avatarUrl!,
roomId: groupCall.room.roomId,
roomName: groupCall.room.name,
};
}, [displayName, avatarUrl, groupCall]);
useEffect(() => {
if (widget && preload) {
@@ -139,14 +138,14 @@ export function GroupCallView({
PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId);
await Promise.all([
widget.api.setAlwaysOnScreen(true),
widget.api.transport.reply(ev.detail, {}),
widget!.api.setAlwaysOnScreen(true),
widget!.api.transport.reply(ev.detail, {}),
]);
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return () => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
}
}, [groupCall, preload, enter]);
@@ -205,12 +204,12 @@ export function GroupCallView({
if (widget && state === GroupCallState.Entered) {
const onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
leave();
await widget.api.transport.reply(ev.detail, {});
widget.api.setAlwaysOnScreen(false);
await widget!.api.transport.reply(ev.detail, {});
widget!.api.setAlwaysOnScreen(false);
};
widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
return () => {
widget.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
widget!.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
};
}
}, [groupCall, state, leave]);
@@ -219,26 +218,14 @@ export function GroupCallView({
undefined
);
const [livekitServiceURL, setLivekitServiceURL] = useState<
string | undefined
>(groupCall.foci[0]?.livekitServiceUrl);
useEffect(() => {
setLivekitServiceURL(groupCall.foci[0]?.livekitServiceUrl);
}, [setLivekitServiceURL, groupCall]);
if (!livekitServiceURL) {
return <ErrorView error={new Error("No livekit_service_url defined")} />;
}
if (error) {
return <ErrorView error={error} />;
} else if (state === GroupCallState.Entered && userChoices) {
return (
<OpenIDLoader
client={client}
livekitServiceURL={livekitServiceURL}
roomName={matrixInfo.roomName}
groupCall={groupCall}
roomName={`${groupCall.room.roomId}-${groupCall.groupCallId}`}
>
<ActiveCall
client={client}
@@ -247,7 +234,6 @@ export function GroupCallView({
onLeave={onLeave}
unencryptedEventsFromUsers={unencryptedEventsFromUsers}
hideHeader={hideHeader}
matrixInfo={matrixInfo}
userChoices={userChoices}
otelGroupCallMembership={otelGroupCallMembership}
/>

View File

@@ -68,22 +68,21 @@ import { ElementWidgetActions, widget } from "../widget";
import { GridLayoutMenu } from "./GridLayoutMenu";
import { GroupCallInspector } from "./GroupCallInspector";
import styles from "./InCallView.module.css";
import { MatrixInfo } from "./VideoPreview";
import { useJoinRule } from "./useJoinRule";
import { ParticipantInfo } from "./useGroupCall";
import { ItemData, TileContent } from "../video-grid/VideoTile";
import { ItemData, TileContent, VideoTile } from "../video-grid/VideoTile";
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
import { SettingsModal } from "../settings/SettingsModal";
import { InviteModal } from "./InviteModal";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { VideoTile } from "../video-grid/VideoTile";
import { UserChoices, useLiveKit } from "../livekit/useLiveKit";
import { useMediaDevices } from "../livekit/useMediaDevices";
import { useMediaDevicesSwitcher } from "../livekit/useMediaDevicesSwitcher";
import { useFullscreen } from "./useFullscreen";
import { useLayoutStates } from "../video-grid/Layout";
import { useSFUConfig } from "../livekit/OpenIDLoader";
import { E2EELock } from "../E2EELock";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@@ -99,12 +98,14 @@ export function ActiveCall(props: ActiveCallProps) {
const sfuConfig = useSFUConfig();
const livekitRoom = useLiveKit(props.userChoices, sfuConfig);
if (!livekitRoom) {
return null;
}
return (
livekitRoom && (
<RoomContext.Provider value={livekitRoom}>
<InCallView {...props} livekitRoom={livekitRoom} />
</RoomContext.Provider>
)
<RoomContext.Provider value={livekitRoom}>
<InCallView {...props} livekitRoom={livekitRoom} />
</RoomContext.Provider>
);
}
@@ -116,8 +117,7 @@ export interface InCallViewProps {
onLeave: () => void;
unencryptedEventsFromUsers: Set<string>;
hideHeader: boolean;
matrixInfo: MatrixInfo;
otelGroupCallMembership: OTelGroupCallMembership;
otelGroupCallMembership?: OTelGroupCallMembership;
}
export function InCallView({
@@ -128,7 +128,6 @@ export function InCallView({
onLeave,
unencryptedEventsFromUsers,
hideHeader,
matrixInfo,
otelGroupCallMembership,
}: InCallViewProps) {
const { t } = useTranslation();
@@ -147,7 +146,7 @@ export function InCallView({
);
// Managed media devices state coupled with an active room.
const roomMediaDevices = useMediaDevices(livekitRoom);
const roomMediaSwitcher = useMediaDevicesSwitcher(livekitRoom);
const screenSharingTracks = useTracks(
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
@@ -202,11 +201,11 @@ export function InCallView({
if (widget) {
const onTileLayout = async (ev: CustomEvent<IWidgetApiRequest>) => {
setLayout("freedom");
await widget.api.transport.reply(ev.detail, {});
await widget!.api.transport.reply(ev.detail, {});
};
const onSpotlightLayout = async (ev: CustomEvent<IWidgetApiRequest>) => {
setLayout("spotlight");
await widget.api.transport.reply(ev.detail, {});
await widget!.api.transport.reply(ev.detail, {});
};
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
@@ -216,8 +215,8 @@ export function InCallView({
);
return () => {
widget.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
widget.lazyActions.off(
widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
widget!.lazyActions.off(
ElementWidgetActions.SpotlightLayout,
onSpotlightLayout
);
@@ -340,7 +339,12 @@ export function InCallView({
const toggleScreensharing = useCallback(async () => {
exitFullscreen();
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled);
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
audio: true,
selfBrowserSurface: "include",
surfaceSwitching: "include",
systemAudio: "include",
});
}, [localParticipant, isScreenShareEnabled, exitFullscreen]);
let footer: JSX.Element | null;
@@ -390,11 +394,12 @@ export function InCallView({
{!hideHeader && maximisedParticipant === null && (
<Header>
<LeftNav>
<RoomHeaderInfo roomName={matrixInfo.roomName} />
<RoomHeaderInfo roomName={groupCall.room.name} />
<VersionMismatchWarning
users={unencryptedEventsFromUsers}
room={groupCall.room}
/>
<E2EELock />
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
@@ -409,31 +414,30 @@ export function InCallView({
{renderContent()}
{footer}
</div>
<GroupCallInspector
client={client}
groupCall={groupCall}
otelGroupCallMembership={otelGroupCallMembership}
show={showInspector}
/>
{otelGroupCallMembership && (
<GroupCallInspector
client={client}
groupCall={groupCall}
otelGroupCallMembership={otelGroupCallMembership}
show={showInspector}
/>
)}
{rageshakeRequestModalState.isOpen && !noControls && (
<RageshakeRequestModal
{...rageshakeRequestModalProps}
roomIdOrAlias={matrixInfo.roomIdOrAlias}
roomId={groupCall.room.roomId}
/>
)}
{settingsModalState.isOpen && (
<SettingsModal
client={client}
roomId={groupCall.room.roomId}
mediaDevices={roomMediaDevices}
mediaDevicesSwitcher={roomMediaSwitcher}
{...settingsModalProps}
/>
)}
{inviteModalState.isOpen && (
<InviteModal
roomIdOrAlias={matrixInfo.roomIdOrAlias}
{...inviteModalProps}
/>
<InviteModal roomId={groupCall.room.roomId} {...inviteModalProps} />
)}
</div>
);

View File

@@ -23,10 +23,10 @@ import { getRoomUrl } from "../matrix-utils";
import styles from "./InviteModal.module.css";
interface Props extends Omit<ModalProps, "title" | "children"> {
roomIdOrAlias: string;
roomId: string;
}
export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => {
export const InviteModal: FC<Props> = ({ roomId, ...rest }) => {
const { t } = useTranslation();
return (
@@ -40,7 +40,7 @@ export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => {
<p>{t("Copy and share this call link")}</p>
<CopyButton
className={styles.copyButton}
value={getRoomUrl(roomIdOrAlias)}
value={getRoomUrl(roomId)}
data-testid="modal_inviteLink"
/>
</ModalContent>

View File

@@ -39,7 +39,7 @@ export function LobbyView(props: Props) {
const { t } = useTranslation();
useLocationNavigation();
const joinCallButtonRef = useRef<HTMLButtonElement>();
const joinCallButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (joinCallButtonRef.current) {
joinCallButtonRef.current.focus();
@@ -81,7 +81,7 @@ export function LobbyView(props: Props) {
<Body>Or</Body>
<CopyButton
variant="secondaryCopy"
value={getRoomUrl(props.matrixInfo.roomName)}
value={getRoomUrl(props.matrixInfo.roomId)}
className={styles.copyButton}
copiedMessage={t("Call link copied")}
data-testid="lobby_inviteLink"

View File

@@ -25,13 +25,13 @@ import { Body } from "../typography/Typography";
interface Props extends Omit<ModalProps, "title" | "children"> {
rageshakeRequestId: string;
roomIdOrAlias: string;
roomId: string;
onClose: () => void;
}
export const RageshakeRequestModal: FC<Props> = ({
rageshakeRequestId,
roomIdOrAlias,
roomId,
...rest
}) => {
const { t } = useTranslation();
@@ -57,7 +57,7 @@ export const RageshakeRequestModal: FC<Props> = ({
submitRageshake({
sendLogs: true,
rageshakeRequestId,
roomId: roomIdOrAlias, // Possibly not a room ID, but oh well
roomId,
})
}
disabled={sending}

View File

@@ -26,15 +26,18 @@ import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Form } from "../form/Form";
import { UserMenuContainer } from "../UserMenuContainer";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { Config } from "../config/Config";
export function RoomAuthView() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const { registerPasswordlessUser, recaptchaId, privacyPolicyUrl } =
const { registerPasswordlessUser, recaptchaId } =
useRegisterPasswordlessUser();
const onSubmit = useCallback(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(e) => {
e.preventDefault();
const data = new FormData(e.target);
@@ -83,7 +86,7 @@ export function RoomAuthView() {
<Caption>
<Trans>
By clicking "Join call now", you agree to our{" "}
<Link href={privacyPolicyUrl}>
<Link href={Config.get().eula}>
End User Licensing Agreement (EULA)
</Link>
</Trans>

View File

@@ -18,7 +18,7 @@ import { FC, useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { useClient } from "../ClientContext";
import { useClientLegacy } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
import { RoomAuthView } from "./RoomAuthView";
import { GroupCallLoader } from "./GroupCallLoader";
@@ -30,8 +30,6 @@ import { useOptInAnalytics } from "../settings/useSetting";
export const RoomPage: FC = () => {
const { t } = useTranslation();
const { loading, isAuthenticated, error, client, isPasswordlessUser } =
useClient();
const {
roomAlias,
@@ -52,39 +50,41 @@ export const RoomPage: FC = () => {
useEffect(() => {
// During the beta, opt into analytics by default
if (optInAnalytics === null) setOptInAnalytics(true);
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
}, [optInAnalytics, setOptInAnalytics]);
const { loading, authenticated, client, error, passwordlessUser } =
useClientLegacy();
useEffect(() => {
// If we've finished loading, are not already authed and we've been given a display name as
// a URL param, automatically register a passwordless user
if (!loading && !isAuthenticated && displayName) {
if (!loading && !authenticated && displayName) {
setIsRegistering(true);
registerPasswordlessUser(displayName).finally(() => {
setIsRegistering(false);
});
}
}, [
isAuthenticated,
loading,
authenticated,
displayName,
setIsRegistering,
registerPasswordlessUser,
loading,
]);
const groupCallView = useCallback(
(groupCall: GroupCall) => (
<GroupCallView
client={client}
roomIdOrAlias={roomIdOrAlias}
client={client!}
groupCall={groupCall}
isPasswordlessUser={isPasswordlessUser}
isPasswordlessUser={passwordlessUser}
isEmbedded={isEmbedded}
preload={preload}
hideHeader={hideHeader}
/>
),
[client, roomIdOrAlias, isPasswordlessUser, isEmbedded, preload, hideHeader]
[client, passwordlessUser, isEmbedded, preload, hideHeader]
);
if (loading || isRegistering) {
@@ -95,7 +95,7 @@ export const RoomPage: FC = () => {
return <ErrorView error={error} />;
}
if (!isAuthenticated) {
if (!client) {
return <RoomAuthView />;
}

View File

@@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { useState, useEffect, useRef, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { OverlayTriggerState } from "@react-stately/overlays";
import { usePreviewDevice } from "@livekit/components-react";
import { usePreviewTracks } from "@livekit/components-react";
import { LocalAudioTrack, LocalVideoTrack, Track } from "livekit-client";
import { MicButton, SettingsButton, VideoButton } from "../button";
import { Avatar } from "../Avatar";
@@ -26,15 +27,15 @@ import styles from "./VideoPreview.module.css";
import { useModalTriggerState } from "../Modal";
import { SettingsModal } from "../settings/SettingsModal";
import { useClient } from "../ClientContext";
import { useMediaDevices } from "../livekit/useMediaDevices";
import { DeviceChoices, UserChoices } from "../livekit/useLiveKit";
import { useMediaDevicesSwitcher } from "../livekit/useMediaDevicesSwitcher";
import { UserChoices } from "../livekit/useLiveKit";
import { useDefaultDevices } from "../settings/useSetting";
export type MatrixInfo = {
displayName: string;
avatarUrl: string;
roomId: string;
roomName: string;
roomIdOrAlias: string;
};
interface Props {
@@ -61,85 +62,111 @@ export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
settingsModalState.open();
}, [settingsModalState]);
// Fetch user media devices.
const mediaDevices = useMediaDevices();
// Create local media tracks.
const [videoEnabled, setVideoEnabled] = useState<boolean>(true);
const [audioEnabled, setAudioEnabled] = useState<boolean>(true);
const [videoId, audioId] = [
mediaDevices.videoIn.selectedId,
mediaDevices.audioIn.selectedId,
];
const [defaultDevices] = useDefaultDevices();
const video = usePreviewDevice(
videoEnabled,
videoId != "" ? videoId : defaultDevices.videoinput,
"videoinput"
// The settings are updated as soon as the device changes. We wrap the settings value in a ref to store their initial value.
// Not changing the device options prohibits the usePreviewTracks hook to recreate the tracks.
const initialDefaultDevices = useRef(useDefaultDevices()[0]);
const tracks = usePreviewTracks(
{
audio: { deviceId: initialDefaultDevices.current.audioinput },
video: { deviceId: initialDefaultDevices.current.videoinput },
},
(error) => {
console.error("Error while creating preview Tracks:", error);
}
);
const audio = usePreviewDevice(
audioEnabled,
audioId != "" ? audioId : defaultDevices.audioinput,
"audioinput"
const videoTrack = React.useMemo(
() =>
tracks?.filter((t) => t.kind === Track.Kind.Video)[0] as LocalVideoTrack,
[tracks]
);
const audioTrack = React.useMemo(
() =>
tracks?.filter((t) => t.kind === Track.Kind.Audio)[0] as LocalAudioTrack,
[tracks]
);
const activeVideoId = video?.selectedDevice?.deviceId;
const activeAudioId = audio?.selectedDevice?.deviceId;
// Only let the MediaDeviceSwitcher request permissions if a video track is already available.
// Otherwise we would end up asking for permissions in usePreviewTracks and in useMediaDevicesSwitcher.
const requestPermissions = !!audioTrack && !!videoTrack;
const mediaSwitcher = useMediaDevicesSwitcher(
undefined,
{ videoTrack, audioTrack },
requestPermissions
);
const { videoIn, audioIn } = mediaSwitcher;
const videoEl = React.useRef(null);
useEffect(() => {
const createChoices = (
enabled: boolean,
deviceId?: string
): DeviceChoices | undefined => {
if (deviceId === undefined) {
return undefined;
}
return {
selectedId: deviceId,
enabled,
};
};
// Effect to update the settings
onUserChoicesChanged({
video: createChoices(videoEnabled, activeVideoId),
audio: createChoices(audioEnabled, activeAudioId),
video: {
selectedId: videoIn.selectedId,
enabled: videoEnabled && !!videoTrack,
},
audio: {
selectedId: audioIn.selectedId,
enabled: audioEnabled && !!audioTrack,
},
});
}, [
onUserChoicesChanged,
activeVideoId,
videoIn.selectedId,
videoEnabled,
activeAudioId,
audioIn.selectedId,
audioEnabled,
videoTrack,
audioTrack,
]);
const [selectVideo, selectAudio] = [
mediaDevices.videoIn.setSelected,
mediaDevices.audioIn.setSelected,
];
useEffect(() => {
if (activeVideoId && activeVideoId !== "") {
selectVideo(activeVideoId);
// Effect to update the initial device selection for the ui elements based on the current preview track.
if (!videoIn.selectedId || videoIn.selectedId == "") {
videoTrack?.getDeviceId().then((videoId) => {
videoIn.setSelected(videoId ?? "default");
});
}
if (activeAudioId && activeAudioId !== "") {
selectAudio(activeAudioId);
if (!audioIn.selectedId || audioIn.selectedId == "") {
audioTrack?.getDeviceId().then((audioId) => {
// getDeviceId() can return undefined for audio devices. This happens if
// the devices list uses "default" as the device id for the current
// device and the device set on the track also uses the deviceId
// "default". Check `normalizeDeviceId` in `getDeviceId` for more
// details.
audioIn.setSelected(audioId ?? "default");
});
}
}, [selectVideo, selectAudio, activeVideoId, activeAudioId]);
}, [videoIn, audioIn, videoTrack, audioTrack]);
const mediaElement = useRef(null);
useEffect(() => {
if (mediaElement.current) {
video?.localTrack?.attach(mediaElement.current);
// Effect to connect the videoTrack with the video element.
if (videoEl.current) {
videoTrack?.unmute();
videoTrack?.attach(videoEl.current);
}
return () => {
video?.localTrack?.detach();
videoTrack?.detach();
};
}, [video?.localTrack, mediaElement]);
}, [videoTrack]);
useEffect(() => {
// Effect to mute/unmute video track. (This has to be done, so that the hardware camera indicator does not confuse the user)
if (videoTrack && videoEnabled) {
videoTrack?.unmute();
} else if (videoTrack) {
videoTrack?.mute();
}
}, [videoEnabled, videoTrack]);
return (
<div className={styles.preview} ref={previewRef}>
<video ref={mediaElement} muted playsInline disablePictureInPicture />
<video ref={videoEl} muted playsInline disablePictureInPicture />
<>
{(video ? !videoEnabled : true) && (
{(videoTrack ? !videoEnabled : true) && (
<div className={styles.avatarContainer}>
<Avatar
size={(previewBounds.height - 66) / 2}
@@ -149,25 +176,23 @@ export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
</div>
)}
<div className={styles.previewButtons}>
{audio.localTrack && (
<MicButton
muted={!audioEnabled}
onPress={() => setAudioEnabled(!audioEnabled)}
/>
)}
{video.localTrack && (
<VideoButton
muted={!videoEnabled}
onPress={() => setVideoEnabled(!videoEnabled)}
/>
)}
<MicButton
muted={!audioEnabled}
onPress={() => setAudioEnabled(!audioEnabled)}
disabled={!audioTrack}
/>
<VideoButton
muted={!videoEnabled}
onPress={() => setVideoEnabled(!videoEnabled)}
disabled={!videoTrack}
/>
<SettingsButton onPress={openSettings} />
</div>
</>
{settingsModalState.isOpen && (
{settingsModalState.isOpen && client && (
<SettingsModal
client={client}
mediaDevices={mediaDevices}
mediaDevicesSwitcher={mediaSwitcher}
{...settingsModalProps}
/>
)}

View File

@@ -58,12 +58,12 @@ export interface ParticipantInfo {
interface UseGroupCallReturnType {
state: GroupCallState;
localCallFeed: CallFeed;
activeSpeaker: CallFeed | null;
localCallFeed?: CallFeed;
activeSpeaker?: CallFeed;
userMediaFeeds: CallFeed[];
microphoneMuted: boolean;
localVideoMuted: boolean;
error: TranslatedError | null;
error?: TranslatedError;
initLocalCallFeed: () => void;
enter: () => Promise<void>;
leave: () => void;
@@ -74,23 +74,21 @@ interface UseGroupCallReturnType {
requestingScreenshare: boolean;
isScreensharing: boolean;
screenshareFeeds: CallFeed[];
localDesktopCapturerSourceId: string; // XXX: This looks unused?
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
hasLocalParticipant: boolean;
unencryptedEventsFromUsers: Set<string>;
otelGroupCallMembership: OTelGroupCallMembership;
otelGroupCallMembership?: OTelGroupCallMembership;
}
interface State {
state: GroupCallState;
localCallFeed: CallFeed;
activeSpeaker: CallFeed | null;
localCallFeed?: CallFeed;
activeSpeaker?: CallFeed;
userMediaFeeds: CallFeed[];
error: TranslatedError | null;
error?: TranslatedError;
microphoneMuted: boolean;
localVideoMuted: boolean;
screenshareFeeds: CallFeed[];
localDesktopCapturerSourceId: string;
isScreensharing: boolean;
requestingScreenshare: boolean;
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
@@ -101,7 +99,7 @@ interface State {
// level so that it doesn't pop in & out of existence as react mounts & unmounts
// components. The right solution is probably for this to live in the js-sdk and have
// the same lifetime as groupcalls themselves.
let groupCallOTelMembership: OTelGroupCallMembership;
let groupCallOTelMembership: OTelGroupCallMembership | undefined;
let groupCallOTelMembershipGroupCallId: string;
function getParticipants(
@@ -159,7 +157,6 @@ export function useGroupCall(
localVideoMuted,
isScreensharing,
screenshareFeeds,
localDesktopCapturerSourceId,
participants,
hasLocalParticipant,
requestingScreenshare,
@@ -167,15 +164,11 @@ export function useGroupCall(
setState,
] = useState<State>({
state: GroupCallState.LocalCallFeedUninitialized,
localCallFeed: null,
activeSpeaker: null,
userMediaFeeds: [],
error: null,
microphoneMuted: false,
localVideoMuted: false,
isScreensharing: false,
screenshareFeeds: [],
localDesktopCapturerSourceId: null,
requestingScreenshare: false,
participants: new Map(),
hasLocalParticipant: false,
@@ -248,12 +241,11 @@ export function useGroupCall(
updateState({
state: groupCall.state,
localCallFeed: groupCall.localCallFeed,
activeSpeaker: groupCall.activeSpeaker ?? null,
activeSpeaker: groupCall.activeSpeaker,
userMediaFeeds: [...groupCall.userMediaFeeds],
microphoneMuted: groupCall.isMicrophoneMuted(),
localVideoMuted: groupCall.isLocalVideoMuted(),
isScreensharing: groupCall.isScreensharing(),
localDesktopCapturerSourceId: groupCall.localDesktopCapturerSourceId,
screenshareFeeds: [...groupCall.screenshareFeeds],
});
}
@@ -303,7 +295,7 @@ export function useGroupCall(
function onActiveSpeakerChanged(activeSpeaker: CallFeed | undefined): void {
updateState({
activeSpeaker: activeSpeaker ?? null,
activeSpeaker: activeSpeaker,
});
}
@@ -319,12 +311,11 @@ export function useGroupCall(
function onLocalScreenshareStateChanged(
isScreensharing: boolean,
_localScreenshareFeed: CallFeed,
localDesktopCapturerSourceId: string
_localScreenshareFeed?: CallFeed,
localDesktopCapturerSourceId?: string
): void {
updateState({
isScreensharing,
localDesktopCapturerSourceId,
});
}
@@ -405,15 +396,14 @@ export function useGroupCall(
);
updateState({
error: null,
error: undefined,
state: groupCall.state,
localCallFeed: groupCall.localCallFeed,
activeSpeaker: groupCall.activeSpeaker ?? null,
activeSpeaker: groupCall.activeSpeaker,
userMediaFeeds: [...groupCall.userMediaFeeds],
microphoneMuted: groupCall.isMicrophoneMuted(),
localVideoMuted: groupCall.isLocalVideoMuted(),
isScreensharing: groupCall.isScreensharing(),
localDesktopCapturerSourceId: groupCall.localDesktopCapturerSourceId,
screenshareFeeds: [...groupCall.screenshareFeeds],
participants: getParticipants(groupCall),
hasLocalParticipant: groupCall.hasLocalParticipant(),
@@ -516,7 +506,7 @@ export function useGroupCall(
}, [groupCall]);
const setMicrophoneMuted = useCallback(
(setMuted) => {
(setMuted: boolean) => {
groupCall.setMicrophoneMuted(setMuted);
groupCallOTelMembership?.onSetMicrophoneMuted(setMuted);
PosthogAnalytics.instance.eventMuteMicrophone.track(
@@ -575,7 +565,7 @@ export function useGroupCall(
desktopCapturerSourceId: data.desktopCapturerSourceId as string,
audio: !data.desktopCapturerSourceId,
});
await widget.api.transport.reply(ev.detail, {});
await widget?.api.transport.reply(ev.detail, {});
},
[groupCall, updateState]
);
@@ -584,7 +574,7 @@ export function useGroupCall(
async (ev: CustomEvent<IWidgetApiRequest>) => {
updateState({ requestingScreenshare: false });
await groupCall.setScreensharingEnabled(false);
await widget.api.transport.reply(ev.detail, {});
await widget?.api.transport.reply(ev.detail, {});
},
[groupCall, updateState]
);
@@ -601,11 +591,11 @@ export function useGroupCall(
);
return () => {
widget.lazyActions.off(
widget?.lazyActions.off(
ElementWidgetActions.ScreenshareStart,
onScreenshareStart
);
widget.lazyActions.off(
widget?.lazyActions.off(
ElementWidgetActions.ScreenshareStop,
onScreenshareStop
);
@@ -644,7 +634,6 @@ export function useGroupCall(
requestingScreenshare,
isScreensharing,
screenshareFeeds,
localDesktopCapturerSourceId,
participants,
hasLocalParticipant,
unencryptedEventsFromUsers,

View File

@@ -34,8 +34,26 @@ import { widget } from "../widget";
const STATS_COLLECT_INTERVAL_TIME_MS = 10000;
export type GroupCallLoaded = {
kind: "loaded";
groupCall: GroupCall;
};
export type GroupCallLoadFailed = {
kind: "failed";
error: Error;
};
export type GroupCallLoading = {
kind: "loading";
};
export type GroupCallStatus =
| GroupCallLoaded
| GroupCallLoadFailed
| GroupCallLoading;
export interface GroupCallLoadState {
loading: boolean;
error?: Error;
groupCall?: GroupCall;
}
@@ -45,13 +63,11 @@ export const useLoadGroupCall = (
roomIdOrAlias: string,
viaServers: string[],
createPtt: boolean
): GroupCallLoadState => {
): GroupCallStatus => {
const { t } = useTranslation();
const [state, setState] = useState<GroupCallLoadState>({ loading: true });
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
useEffect(() => {
setState({ loading: true });
const fetchOrCreateRoom = async (): Promise<Room> => {
try {
// We lowercase the localpart when we create the room, so we must lowercase
@@ -74,8 +90,14 @@ export const useLoadGroupCall = (
} catch (error) {
if (
isLocalRoomId(roomIdOrAlias, client) &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(error.errcode === "M_NOT_FOUND" ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(error.message &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
error.message.indexOf("Failed to fetch alias") !== -1))
) {
// The room doesn't exist, but we can create it
@@ -86,7 +108,7 @@ export const useLoadGroupCall = (
);
// likewise, wait for the room
await client.waitUntilRoomReadyForGroupCalls(roomId);
return client.getRoom(roomId);
return client.getRoom(roomId)!;
} else {
throw error;
}
@@ -170,12 +192,8 @@ export const useLoadGroupCall = (
waitForClientSyncing()
.then(fetchOrCreateGroupCall)
.then((groupCall) =>
setState((prevState) => ({ ...prevState, loading: false, groupCall }))
)
.catch((error) =>
setState((prevState) => ({ ...prevState, loading: false, error }))
);
.then((groupCall) => setState({ kind: "loaded", groupCall }))
.catch((error) => setState({ kind: "failed", error }));
}, [client, roomIdOrAlias, viaServers, createPtt, t]);
return state;

View File

@@ -55,14 +55,22 @@ export function usePageUnload(callback: () => void) {
// iOS doesn't fire beforeunload event, so leave the call when you hide the page.
if (isIOS()) {
window.addEventListener("pagehide", onBeforeUnload);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
document.addEventListener("visibilitychange", onBeforeUnload);
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
window.removeEventListener("pagehide", onBeforeUnload);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
document.removeEventListener("visibilitychange", onBeforeUnload);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.removeEventListener("beforeunload", onBeforeUnload);
clearTimeout(pageVisibilityTimeout);
};

View File

@@ -35,6 +35,8 @@ export function FeedbackSettingsTab({ roomId }: Props) {
const sendRageshakeRequest = useRageshakeRequest();
const onSubmitFeedback = useCallback(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(e) => {
e.preventDefault();
const data = new FormData(e.target);

View File

@@ -59,8 +59,14 @@ export function ProfileSettingsTab({ client }: Props) {
? displayNameDataEntry
: displayNameDataEntry?.name ?? null;
if (!displayName) {
return;
}
saveProfile({
displayName,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
avatar: avatar && avatarSize > 0 ? avatar : undefined,
removeAvatar: removeAvatar.current && (!avatar || avatarSize === 0),
});
@@ -71,14 +77,16 @@ export function ProfileSettingsTab({ client }: Props) {
return (
<form onChange={onFormChange} ref={formRef} className={styles.content}>
<FieldRow className={styles.avatarFieldRow}>
<AvatarInputField
id="avatar"
name="avatar"
label={t("Avatar")}
avatarUrl={avatarUrl}
displayName={displayName}
onRemoveAvatar={onRemoveAvatar}
/>
{avatarUrl && displayName && (
<AvatarInputField
id="avatar"
name="avatar"
label={t("Avatar")}
avatarUrl={avatarUrl}
displayName={displayName}
onRemoveAvatar={onRemoveAvatar}
/>
)}
</FieldRow>
<FieldRow>
<InputField

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ChangeEvent, useCallback, useState } from "react";
import { ChangeEvent, Key, useCallback, useState } from "react";
import { Item } from "@react-stately/collections";
import { Trans, useTranslation } from "react-i18next";
import { MatrixClient } from "matrix-js-sdk";
@@ -42,10 +42,14 @@ import { Body, Caption } from "../typography/Typography";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { ProfileSettingsTab } from "./ProfileSettingsTab";
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
import { MediaDevices, MediaDevicesState } from "../livekit/useMediaDevices";
import {
MediaDevices,
MediaDevicesState,
} from "../livekit/useMediaDevicesSwitcher";
import { useUrlParams } from "../UrlParams";
interface Props {
mediaDevices?: MediaDevicesState;
mediaDevicesSwitcher?: MediaDevicesState;
isOpen: boolean;
client: MatrixClient;
roomId?: string;
@@ -56,6 +60,8 @@ interface Props {
export const SettingsModal = (props: Props) => {
const { t } = useTranslation();
const { isEmbedded } = useUrlParams();
const [showInspector, setShowInspector] = useShowInspector();
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const [developerSettingsTab, setDeveloperSettingsTab] =
@@ -72,7 +78,11 @@ export const SettingsModal = (props: Props) => {
return (
<SelectInput
label={caption}
selectedKey={devices.selectedId}
selectedKey={
devices.selectedId === "" || !devices.selectedId
? "default"
: devices.selectedId
}
onSelectionChange={(id) => devices.setSelected(id.toString())}
>
{devices.available.map(({ deviceId, label }, index) => (
@@ -89,8 +99,8 @@ export const SettingsModal = (props: Props) => {
const [selectedTab, setSelectedTab] = useState<string | undefined>();
const onSelectedTabChanged = useCallback(
(tab) => {
setSelectedTab(tab);
(tab: Key) => {
setSelectedTab(tab.toString());
},
[setSelectedTab]
);
@@ -106,7 +116,145 @@ export const SettingsModal = (props: Props) => {
</Caption>
);
const devices = props.mediaDevices;
const devices = props.mediaDevicesSwitcher;
const tabs = [
<TabItem
key="audio"
title={
<>
<AudioIcon width={16} height={16} />
<span className={styles.tabLabel}>{t("Audio")}</span>
</>
}
>
{devices && generateDeviceSelection(devices.audioIn, t("Microphone"))}
{devices && generateDeviceSelection(devices.audioOut, t("Speaker"))}
</TabItem>,
<TabItem
key="video"
title={
<>
<VideoIcon width={16} height={16} />
<span>{t("Video")}</span>
</>
}
>
{devices && generateDeviceSelection(devices.videoIn, t("Camera"))}
</TabItem>,
<TabItem
key="feedback"
title={
<>
<FeedbackIcon width={16} height={16} />
<span>{t("Feedback")}</span>
</>
}
>
<FeedbackSettingsTab roomId={props.roomId} />
</TabItem>,
<TabItem
key="more"
title={
<>
<OverflowIcon width={16} height={16} />
<span>{t("More")}</span>
</>
}
>
<h4>Developer</h4>
<p>Version: {(import.meta.env.VITE_APP_VERSION as string) || "dev"}</p>
<FieldRow>
<InputField
id="developerSettingsTab"
type="checkbox"
checked={developerSettingsTab}
label={t("Developer Settings")}
description={t("Expose developer settings in the settings window.")}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setDeveloperSettingsTab(event.target.checked)
}
/>
</FieldRow>
<h4>Analytics</h4>
<FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics ?? undefined}
description={optInDescription}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setOptInAnalytics?.(event.target.checked);
}}
/>
</FieldRow>
</TabItem>,
];
if (!isEmbedded) {
tabs.push(
<TabItem
key="profile"
title={
<>
<UserIcon width={15} height={15} />
<span>{t("Profile")}</span>
</>
}
>
<ProfileSettingsTab client={props.client} />
</TabItem>
);
}
if (developerSettingsTab) {
tabs.push(
<TabItem
key="developer"
title={
<>
<DeveloperIcon width={16} height={16} />
<span>{t("Developer")}</span>
</>
}
>
<FieldRow>
<Body className={styles.fieldRowText}>
{t("Version: {{version}}", {
version: import.meta.env.VITE_APP_VERSION || "dev",
})}
</Body>
</FieldRow>
<FieldRow>
<InputField
id="showInspector"
name="inspector"
label={t("Show call inspector")}
type="checkbox"
checked={showInspector}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setShowInspector(e.target.checked)
}
/>
</FieldRow>
<FieldRow>
<InputField
id="showConnectionStats"
name="connection-stats"
label={t("Show connection stats")}
type="checkbox"
checked={showConnectionStats}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setShowConnectionStats(e.target.checked)
}
/>
</FieldRow>
<FieldRow>
<Button onPress={downloadDebugLog}>{t("Download debug logs")}</Button>
</FieldRow>
</TabItem>
);
}
return (
<Modal
@@ -121,139 +269,7 @@ export const SettingsModal = (props: Props) => {
selectedKey={selectedTab ?? props.defaultTab ?? "audio"}
className={styles.tabContainer}
>
<TabItem
key="audio"
title={
<>
<AudioIcon width={16} height={16} />
<span className={styles.tabLabel}>{t("Audio")}</span>
</>
}
>
{devices && generateDeviceSelection(devices.audioIn, t("Microphone"))}
{devices && generateDeviceSelection(devices.audioOut, t("Speaker"))}
</TabItem>
<TabItem
key="video"
title={
<>
<VideoIcon width={16} height={16} />
<span>{t("Video")}</span>
</>
}
>
{devices && generateDeviceSelection(devices.videoIn, t("Camera"))}
</TabItem>
<TabItem
key="profile"
title={
<>
<UserIcon width={15} height={15} />
<span>{t("Profile")}</span>
</>
}
>
<ProfileSettingsTab client={props.client} />
</TabItem>
<TabItem
key="feedback"
title={
<>
<FeedbackIcon width={16} height={16} />
<span>{t("Feedback")}</span>
</>
}
>
<FeedbackSettingsTab roomId={props.roomId} />
</TabItem>
<TabItem
key="more"
title={
<>
<OverflowIcon width={16} height={16} />
<span>{t("More")}</span>
</>
}
>
<h4>Developer</h4>
<p>
Version: {(import.meta.env.VITE_APP_VERSION as string) || "dev"}
</p>
<FieldRow>
<InputField
id="developerSettingsTab"
type="checkbox"
checked={developerSettingsTab}
label={t("Developer Settings")}
description={t(
"Expose developer settings in the settings window."
)}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setDeveloperSettingsTab(event.target.checked)
}
/>
</FieldRow>
<h4>Analytics</h4>
<FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics}
description={optInDescription}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}
/>
</FieldRow>
</TabItem>
{developerSettingsTab && (
<TabItem
key="developer"
title={
<>
<DeveloperIcon width={16} height={16} />
<span>{t("Developer")}</span>
</>
}
>
<FieldRow>
<Body className={styles.fieldRowText}>
{t("Version: {{version}}", {
version: import.meta.env.VITE_APP_VERSION || "dev",
})}
</Body>
</FieldRow>
<FieldRow>
<InputField
id="showInspector"
name="inspector"
label={t("Show call inspector")}
type="checkbox"
checked={showInspector}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setShowInspector(e.target.checked)
}
/>
</FieldRow>
<FieldRow>
<InputField
id="showConnectionStats"
name="connection-stats"
label={t("Show connection stats")}
type="checkbox"
checked={showConnectionStats}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setShowConnectionStats(e.target.checked)
}
/>
</FieldRow>
<FieldRow>
<Button onPress={downloadDebugLog}>
{t("Download debug logs")}
</Button>
</FieldRow>
</TabItem>
)}
{tabs}
</TabContainer>
</Modal>
);

View File

@@ -79,11 +79,17 @@ class ConsoleLogger extends EventEmitter {
warn: "W",
error: "E",
};
Object.keys(consoleFunctionsToLevels).forEach((fnName) => {
const level = consoleFunctionsToLevels[fnName];
const originalFn = consoleObj[fnName].bind(consoleObj);
this.originalFunctions[fnName] = originalFn;
consoleObj[fnName] = (...args) => {
Object.entries(consoleFunctionsToLevels).forEach(([name, level]) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const originalFn = consoleObj[name].bind(consoleObj);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.originalFunctions[name] = originalFn;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
consoleObj[name] = (...args) => {
this.log(level, ...args);
originalFn(...args);
};
@@ -147,9 +153,9 @@ class ConsoleLogger extends EventEmitter {
// A class which stores log lines in an IndexedDB instance.
class IndexedDBLogStore {
private index = 0;
private db: IDBDatabase = null;
private flushPromise: Promise<void> = null;
private flushAgainPromise: Promise<void> = null;
private db?: IDBDatabase;
private flushPromise?: Promise<void>;
private flushAgainPromise?: Promise<void>;
private id: string;
constructor(
@@ -175,7 +181,7 @@ class IndexedDBLogStore {
};
req.onerror = () => {
const err = "Failed to open log database: " + req.error.name;
const err = "Failed to open log database: " + req?.error?.name;
logger.error(err);
reject(new Error(err));
};
@@ -264,7 +270,7 @@ class IndexedDBLogStore {
return this.flush();
})
.then(() => {
this.flushAgainPromise = null;
this.flushAgainPromise = undefined;
});
return this.flushAgainPromise;
}
@@ -288,13 +294,13 @@ class IndexedDBLogStore {
};
txn.onerror = (event) => {
logger.error("Failed to flush logs : ", event);
reject(new Error("Failed to write logs: " + txn.error.message));
reject(new Error("Failed to write logs: " + txn?.error?.message));
};
objStore.add(this.generateLogEntry(lines));
const lastModStore = txn.objectStore("logslastmod");
lastModStore.put(this.generateLastModifiedTime());
}).then(() => {
this.flushPromise = null;
this.flushPromise = undefined;
});
return this.flushPromise;
};
@@ -311,11 +317,14 @@ class IndexedDBLogStore {
*/
public async consume(): Promise<LogEntry[]> {
const db = this.db;
if (!db) {
return Promise.reject(new Error("No connected database"));
}
// Returns: a string representing the concatenated logs for this ID.
// Stops adding log fragments when the size exceeds maxSize
function fetchLogs(id: string, maxSize: number): Promise<string> {
const objectStore = db
const objectStore = db!
.transaction("logs", "readonly")
.objectStore("logs");
@@ -325,7 +334,7 @@ class IndexedDBLogStore {
.openCursor(IDBKeyRange.only(id), "prev");
let lines = "";
query.onerror = () => {
reject(new Error("Query failed: " + query.error.message));
reject(new Error("Query failed: " + query?.error?.message));
};
query.onsuccess = () => {
const cursor = query.result;
@@ -346,7 +355,7 @@ class IndexedDBLogStore {
// Returns: A sorted array of log IDs. (newest first)
function fetchLogIds(): Promise<string[]> {
// To gather all the log IDs, query for all records in logslastmod.
const o = db
const o = db!
.transaction("logslastmod", "readonly")
.objectStore("logslastmod");
return selectQuery<{ ts: number; id: string }>(o, undefined, (cursor) => {
@@ -366,7 +375,7 @@ class IndexedDBLogStore {
function deleteLogs(id: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const txn = db.transaction(["logs", "logslastmod"], "readwrite");
const txn = db!.transaction(["logs", "logslastmod"], "readwrite");
const o = txn.objectStore("logs");
// only load the key path, not the data which may be huge
const query = o.index("id").openKeyCursor(IDBKeyRange.only(id));
@@ -384,7 +393,7 @@ class IndexedDBLogStore {
txn.onerror = () => {
reject(
new Error(
"Failed to delete logs for " + `'${id}' : ${txn.error.message}`
"Failed to delete logs for " + `'${id}' : ${txn?.error?.message}`
)
);
};
@@ -395,7 +404,7 @@ class IndexedDBLogStore {
}
const allLogIds = await fetchLogIds();
let removeLogIds = [];
let removeLogIds: number[] = [];
const logs: LogEntry[] = [];
let size = 0;
for (let i = 0; i < allLogIds.length; i++) {
@@ -414,7 +423,7 @@ class IndexedDBLogStore {
if (size >= MAX_LOG_SIZE) {
// the remaining log IDs should be removed. If we go out of
// bounds this is just []
removeLogIds = allLogIds.slice(i + 1);
removeLogIds = allLogIds.slice(i + 1).map((id) => parseInt(id, 10));
break;
}
}
@@ -462,14 +471,14 @@ class IndexedDBLogStore {
*/
function selectQuery<T>(
store: IDBObjectStore,
keyRange: IDBKeyRange,
keyRange: IDBKeyRange | undefined,
resultMapper: (cursor: IDBCursorWithValue) => T
): Promise<T[]> {
const query = store.openCursor(keyRange);
return new Promise((resolve, reject) => {
const results = [];
const results: T[] = [];
query.onerror = () => {
reject(new Error("Query failed: " + query.error.message));
reject(new Error("Query failed: " + query?.error?.message));
};
// collect results
query.onsuccess = () => {

View File

@@ -15,10 +15,12 @@ limitations under the License.
*/
import { useCallback, useContext, useEffect, useState } from "react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import pako from "pako";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { OverlayTriggerState } from "@react-stately/overlays";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { getLogsForReport } from "./rageshake";
import { useClient } from "../ClientContext";
@@ -46,20 +48,27 @@ export function useSubmitRageshake(): {
submitRageshake: (opts: RageShakeSubmitOptions) => Promise<void>;
sending: boolean;
sent: boolean;
error: Error;
error?: Error;
} {
const client: MatrixClient = useClient().client;
const { client } = useClient();
// The value of the context is the whole tuple returned from setState,
// so we just want the current state.
const [inspectorState] = useContext(InspectorContext);
const [{ sending, sent, error }, setState] = useState({
const [{ sending, sent, error }, setState] = useState<{
sending: boolean;
sent: boolean;
error?: Error;
}>({
sending: false,
sent: false,
error: null,
error: undefined,
});
const submitRageshake = useCallback(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
async (opts) => {
if (!Config.get().rageshake?.submit_url) {
throw new Error("No rageshake URL is configured");
@@ -70,7 +79,7 @@ export function useSubmitRageshake(): {
}
try {
setState({ sending: true, sent: false, error: null });
setState({ sending: true, sent: false, error: undefined });
let userAgent = "UNKNOWN";
if (window.navigator && window.navigator.userAgent) {
@@ -104,11 +113,11 @@ export function useSubmitRageshake(): {
body.append("call_backend", "livekit");
if (client) {
const userId = client.getUserId();
const userId = client.getUserId()!;
const user = client.getUser(userId);
body.append("display_name", user?.displayName);
body.append("user_id", client.credentials.userId);
body.append("device_id", client.deviceId);
body.append("display_name", user?.displayName ?? "");
body.append("user_id", client.credentials.userId ?? "");
body.append("device_id", client.deviceId ?? "");
if (opts.roomId) {
body.append("room_id", opts.roomId);
@@ -120,11 +129,11 @@ export function useSubmitRageshake(): {
keys.push(`curve25519:${client.getDeviceCurve25519Key()}`);
}
body.append("device_keys", keys.join(", "));
body.append("cross_signing_key", client.getCrossSigningId());
body.append("cross_signing_key", client.getCrossSigningId()!);
// add cross-signing status information
const crossSigning = client.crypto.crossSigningInfo;
const secretStorage = client.crypto.secretStorage;
const crossSigning = client.crypto!.crossSigningInfo;
const secretStorage = client.crypto!.secretStorage;
body.append(
"cross_signing_ready",
@@ -138,7 +147,7 @@ export function useSubmitRageshake(): {
)
)
);
body.append("cross_signing_key", crossSigning.getId());
body.append("cross_signing_key", crossSigning.getId()!);
body.append(
"cross_signing_privkey_in_secret_storage",
String(
@@ -150,14 +159,17 @@ export function useSubmitRageshake(): {
body.append(
"cross_signing_master_privkey_cached",
String(
!!(pkCache && (await pkCache.getCrossSigningKeyCache("master")))
!!(
pkCache?.getCrossSigningKeyCache &&
(await pkCache.getCrossSigningKeyCache("master"))
)
)
);
body.append(
"cross_signing_self_signing_privkey_cached",
String(
!!(
pkCache &&
pkCache?.getCrossSigningKeyCache &&
(await pkCache.getCrossSigningKeyCache("self_signing"))
)
)
@@ -166,7 +178,7 @@ export function useSubmitRageshake(): {
"cross_signing_user_signing_privkey_cached",
String(
!!(
pkCache &&
pkCache?.getCrossSigningKeyCache &&
(await pkCache.getCrossSigningKeyCache("user_signing"))
)
)
@@ -186,7 +198,7 @@ export function useSubmitRageshake(): {
String(!!(await client.isKeyBackupKeyStored()))
);
const sessionBackupKeyFromCache =
await client.crypto.getSessionBackupPrivateKey();
await client.crypto!.getSessionBackupPrivateKey();
body.append(
"session_backup_key_cached",
String(!!sessionBackupKeyFromCache)
@@ -233,7 +245,7 @@ export function useSubmitRageshake(): {
Object.keys(estimate.usageDetails).forEach((k) => {
body.append(
`storageManager_usage_${k}`,
String(estimate.usageDetails[k])
String(estimate.usageDetails![k])
);
});
}
@@ -271,14 +283,14 @@ export function useSubmitRageshake(): {
);
}
await fetch(Config.get().rageshake?.submit_url, {
await fetch(Config.get().rageshake!.submit_url, {
method: "POST",
body,
});
setState({ sending: false, sent: true, error: null });
setState({ sending: false, sent: true, error: undefined });
} catch (error) {
setState({ sending: false, sent: false, error });
setState({ sending: false, sent: false, error: error as Error });
console.error(error);
}
},
@@ -307,7 +319,7 @@ export function useDownloadDebugLog(): () => void {
el.click();
setTimeout(() => {
URL.revokeObjectURL(url);
el.parentNode.removeChild(el);
el.parentNode!.removeChild(el);
}, 0);
}, [json]);
@@ -321,8 +333,8 @@ export function useRageshakeRequest(): (
const { client } = useClient();
const sendRageshakeRequest = useCallback(
(roomId, rageshakeRequestId) => {
client.sendEvent(roomId, "org.matrix.rageshake_request", {
(roomId: string, rageshakeRequestId: string) => {
client!.sendEvent(roomId, "org.matrix.rageshake_request", {
request_id: rageshakeRequestId,
});
},
@@ -347,10 +359,12 @@ export function useRageshakeRequestModal(roomId: string): {
modalState: OverlayTriggerState;
modalProps: ModalProps;
};
const client: MatrixClient = useClient().client;
const { client } = useClient();
const [rageshakeRequestId, setRageshakeRequestId] = useState<string>();
useEffect(() => {
if (!client) return;
const onEvent = (event: MatrixEvent) => {
const type = event.getType();
@@ -371,5 +385,8 @@ export function useRageshakeRequestModal(roomId: string): {
};
}, [modalState.open, roomId, client, modalState]);
return { modalState, modalProps: { ...modalProps, rageshakeRequestId } };
return {
modalState,
modalProps: { ...modalProps, rageshakeRequestId: rageshakeRequestId ?? "" },
};
}

View File

@@ -26,7 +26,7 @@ limitations under the License.
padding: 0;
margin: 0 auto 24px auto;
gap: 16px;
overflow: scroll;
overflow-y: auto;
max-width: 100%;
}

View File

@@ -32,7 +32,7 @@ export function TabContainer<T extends object>(
props: TabContainerProps<T>
): JSX.Element {
const state = useTabListState<T>(props);
const ref = useRef<HTMLUListElement>();
const ref = useRef<HTMLUListElement>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={classNames(styles.tabContainer, props.className)}>
@@ -53,7 +53,7 @@ interface TabProps<T> {
function Tab<T>({ item, state }: TabProps<T>): JSX.Element {
const { key, rendered } = item;
const ref = useRef<HTMLLIElement>();
const ref = useRef<HTMLLIElement>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
@@ -75,7 +75,7 @@ interface TabPanelProps<T> extends AriaTabPanelProps {
}
function TabPanel<T>({ state, ...props }: TabPanelProps<T>): JSX.Element {
const ref = useRef<HTMLDivElement>();
const ref = useRef<HTMLDivElement>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className={styles.tabPanel}>

View File

@@ -46,7 +46,7 @@ export const Headline = forwardRef<HTMLHeadingElement, TypographyProps>(
{
...rest,
className: classNames(
styles[fontWeight],
styles[fontWeight ?? ""],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
),
@@ -74,7 +74,7 @@ export const Title = forwardRef<HTMLHeadingElement, TypographyProps>(
{
...rest,
className: classNames(
styles[fontWeight],
styles[fontWeight ?? ""],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
),
@@ -102,7 +102,7 @@ export const Subtitle = forwardRef<HTMLParagraphElement, TypographyProps>(
{
...rest,
className: classNames(
styles[fontWeight],
styles[fontWeight ?? ""],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
),
@@ -130,7 +130,7 @@ export const Body = forwardRef<HTMLParagraphElement, TypographyProps>(
{
...rest,
className: classNames(
styles[fontWeight],
styles[fontWeight ?? ""],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
),
@@ -159,7 +159,7 @@ export const Caption = forwardRef<HTMLParagraphElement, TypographyProps>(
...rest,
className: classNames(
styles.caption,
styles[fontWeight],
styles[fontWeight ?? ""],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
),
@@ -188,7 +188,7 @@ export const Micro = forwardRef<HTMLParagraphElement, TypographyProps>(
...rest,
className: classNames(
styles.micro,
styles[fontWeight],
styles[fontWeight ?? ""],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
),
@@ -219,6 +219,8 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
},
ref
) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const Component: string | RouterLink = as || (to ? RouterLink : "a");
let externalLinkProps: { href: string; target: string; rel: string };
@@ -233,12 +235,16 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
return createElement(
Component,
{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
...externalLinkProps,
...rest,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
to: to,
className: classNames(
styles[color],
styles[fontWeight],
styles[fontWeight ?? ""],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
),

View File

@@ -31,8 +31,13 @@ export const useEventTarget = <T extends Event>(
) => {
useEffect(() => {
if (target) {
target.addEventListener(eventType, listener, options);
return () => target.removeEventListener(eventType, listener, options);
target.addEventListener(eventType, listener as EventListener, options);
return () =>
target.removeEventListener(
eventType,
listener as EventListener,
options
);
}
}, [target, eventType, listener, options]);
};

View File

@@ -21,7 +21,9 @@ export function useLocationNavigation(enabled = false): void {
const history = useHistory();
useEffect(() => {
let unblock;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
let unblock = undefined;
if (enabled) {
unblock = history.block((tx) => {
@@ -33,6 +35,8 @@ export function useLocationNavigation(enabled = false): void {
}
return () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (unblock) {
unblock();
}

File diff suppressed because it is too large Load Diff

View File

@@ -60,6 +60,11 @@ interface DragState {
cursorY: number;
}
interface TapData {
tileId: string;
ts: number;
}
interface SlotProps {
style?: CSSProperties;
}
@@ -257,10 +262,7 @@ export function NewVideoGrid<T>({
);
};
const [lastTappedTileId, setLastTappedTileId] = useState<string | undefined>(
undefined
);
const [lastTapTime, setLastTapTime] = useState<number>(0);
const lastTap = useRef<TapData | null>(null);
// Callback for useDrag. We could call useDrag here, but the default
// pattern of spreading {...bind()} across the children to bind the gesture
@@ -269,22 +271,34 @@ export function NewVideoGrid<T>({
// gesture using the much more sensible ref-based method.
const onTileDrag = (
tileId: string,
{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
tap,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
initial: [initialX, initialY],
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delta: [dx, dy],
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
last,
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
) => {
if (tap) {
const now = Date.now();
if (tileId === lastTappedTileId && now - lastTapTime < 500) {
if (
tileId === lastTap.current?.tileId &&
now - lastTap.current.ts < 500
) {
toggleFocus?.(items.find((i) => i.id === tileId)!);
lastTap.current = null;
} else {
lastTap.current = { tileId, ts: now };
}
setLastTappedTileId(tileId);
setLastTapTime(now);
} else {
const tileController = springRef.current.find(
(c) => (c.item as Tile<T>).item.id === tileId
@@ -320,6 +334,8 @@ export function NewVideoGrid<T>({
const scrollOffset = useRef(0);
useScroll(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
({ xy: [, y], delta: [, dy] }) => {
scrollOffset.current = y;

View File

@@ -695,7 +695,7 @@ function getSubGridPositions(
// Calculates the number of possible tiles that can be displayed
function displayedTileCount(
layout: Layout,
tileCount,
tileCount: number,
gridWidth: number,
gridHeight: number
): number {
@@ -854,7 +854,7 @@ export function VideoGrid<T>({
tilePositions: [],
});
const [scrollPosition, setScrollPosition] = useState<number>(0);
const draggingTileRef = useRef<DragTileData>(null);
const draggingTileRef = useRef<DragTileData | null>(null);
const lastTappedRef = useRef<{ [index: Key]: number }>({});
const lastLayoutRef = useRef<Layout>(layout);
const isMounted = useIsMounted();
@@ -1189,11 +1189,23 @@ export function VideoGrid<T>({
const onTileDrag = (
tileId: string,
{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
active,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
xy,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
movement,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
tap,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
last,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
event,
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
) => {
@@ -1345,7 +1357,11 @@ export function VideoGrid<T>({
const bindGrid = useGesture(
{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
onWheel: (e) => onGridGesture(e, true),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
onDrag: (e) => onGridGesture(e, false),
},
{}

View File

@@ -34,7 +34,9 @@ import styles from "./VideoTile.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
import { useReactiveState } from "../useReactiveState";
import { FullscreenButton } from "../button/Button";
import { AudioButton, FullscreenButton } from "../button/Button";
import { useModalTriggerState } from "../Modal";
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
export interface ItemData {
id: string;
@@ -111,10 +113,14 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
onToggleFullscreen(data.id);
}, [data, onToggleFullscreen]);
const {
modalState: videoTileSettingsModalState,
modalProps: videoTileSettingsModalProps,
} = useModalTriggerState();
const onOptionsPress = videoTileSettingsModalState.open;
const toolbarButtons: JSX.Element[] = [];
if (!sfuParticipant.isLocal) {
// TODO local volume option, which would also go here
if (content === TileContent.ScreenShare) {
toolbarButtons.push(
<FullscreenButton
@@ -124,6 +130,16 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
onPress={onFullscreen}
/>
);
} else {
// Due to the LK SDK this sadly only works for user-media atm
toolbarButtons.push(
<AudioButton
key="localVolume"
className={styles.button}
volume={(sfuParticipant as RemoteParticipant).getVolume() ?? 0}
onPress={onOptionsPress}
/>
);
}
}
@@ -182,6 +198,12 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
: Track.Source.ScreenShare
}
/>
{videoTileSettingsModalState.isOpen && !maximised && (
<VideoTileSettingsModal
{...videoTileSettingsModalProps}
data={data}
/>
)}
</animated.div>
);
}

View File

@@ -0,0 +1,120 @@
/*
Copyright 2022 - 2023 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.
*/
.videoTileSettingsModal {
width: 700px;
height: 316px;
display: flex;
}
.content {
position: relative;
margin: 27px 34px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.localVolumePercentage {
width: 3ch;
}
.localVolumeSlider[type="range"] {
-ms-appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
background-color: transparent;
--slider-color: var(--quinary-content);
--slider-height: 4px;
--thumb-color: var(--accent);
--thumb-radius: 100%;
--thumb-size: 16px;
--thumb-margin-top: -6px;
cursor: pointer;
width: 100%;
}
.localVolumeSlider[type="range"]::-moz-range-track {
-moz-appearance: none;
appearance: none;
background-color: var(--slider-color);
height: var(--slider-height);
}
.localVolumeSlider[type="range"]::-ms-track {
-ms-appearance: none;
appearance: none;
background-color: var(--slider-color);
height: var(--slider-height);
}
.localVolumeSlider[type="range"]::-webkit-slider-runnable-track {
-webkit-appearance: none;
appearance: none;
background-color: var(--slider-color);
height: var(--slider-height);
}
.localVolumeSlider[type="range"]::-moz-range-thumb {
-moz-appearance: none;
appearance: none;
height: var(--thumb-size);
width: var(--thumb-size);
margin-top: var(--thumb-margin-top);
border-radius: var(--thumb-radius);
background: var(--thumb-color);
}
.localVolumeSlider[type="range"]::-ms-thumb {
-ms-appearance: none;
appearance: none;
height: var(--thumb-size);
width: var(--thumb-size);
margin-top: var(--thumb-margin-top);
border-radius: var(--thumb-radius);
background: var(--thumb-color);
}
.localVolumeSlider[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: var(--thumb-size);
width: var(--thumb-size);
margin-top: var(--thumb-margin-top);
border-radius: var(--thumb-radius);
background: var(--thumb-color);
}
.localVolumeSlider[type="range"]::-moz-range-progress {
-moz-appearance: none;
appearance: none;
height: var(--slider-height);
background: var(--thumb-color);
}
.localVolumeSlider[type="range"]::-ms-fill-lower {
-moz-appearance: none;
appearance: none;
height: var(--slider-height);
background: var(--thumb-color);
}

View File

@@ -0,0 +1,85 @@
/*
Copyright 2022 - 2023 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 React, { ChangeEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { RemoteParticipant } from "livekit-client";
import { FieldRow } from "../input/Input";
import { Modal } from "../Modal";
import styles from "./VideoTileSettingsModal.module.css";
import { VolumeIcon } from "../button/VolumeIcon";
import { ItemData } from "./VideoTile";
interface LocalVolumeProps {
participant: RemoteParticipant;
}
const LocalVolume: React.FC<LocalVolumeProps> = ({
participant,
}: LocalVolumeProps) => {
const [localVolume, setLocalVolume] = useState<number>(
participant.getVolume() ?? 0
);
const onLocalVolumeChanged = (event: ChangeEvent<HTMLInputElement>) => {
const value: number = +event.target.value;
setLocalVolume(value);
participant.setVolume(value);
};
return (
<>
<FieldRow>
<VolumeIcon volume={localVolume} />
<input
className={styles.localVolumeSlider}
type="range"
min="0"
max="1"
step="0.01"
value={localVolume}
onChange={onLocalVolumeChanged}
/>
</FieldRow>
</>
);
};
// TODO: Extend ModalProps
interface Props {
data: ItemData;
onClose: () => void;
}
export const VideoTileSettingsModal = ({ data, onClose, ...rest }: Props) => {
const { t } = useTranslation();
return (
<Modal
className={styles.videoTileSettingsModal}
title={t("Local volume")}
isDismissable
mobileFullScreen
onClose={onClose}
{...rest}
>
<div className={styles.content}>
<LocalVolume participant={data.sfuParticipant as RemoteParticipant} />
</div>
</Modal>
);
};

View File

@@ -23,6 +23,7 @@ import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { IWidgetApiRequest } from "matrix-widget-api";
import { LazyEventEmitter } from "./LazyEventEmitter";
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
// Subset of the actions in matrix-react-sdk
export enum ElementWidgetActions {
@@ -156,9 +157,33 @@ export const widget: WidgetHelpers | null = (() => {
timelineSupport: true,
useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: allowIceFallback,
// XXX: The client expects the list of foci in its constructor, but we don't
// know this until we fetch the config file. However, we can't wait to construct
// the client object or we'll miss the 'capabilities' request from the host app.
// As of writing this, I have made the embedded widget client send the 'contentLoaded'
// message so that we can use the widget API in less racy mode, but we need to change
// element-web to use waitForIFrameLoad=false. Once that change has rolled out,
// we can just start the client after we've fetched the config.
livekitServiceURL: undefined,
}
);
const clientPromise = client.startClient().then(() => client);
const clientPromise = new Promise<MatrixClient>((resolve) => {
(async () => {
// wait for the config file to be ready (we load very early on so it might not
// be otherwise)
await Config.init();
const livekit = Config.get().livekit;
const focus = livekit?.livekit_service_url;
// Now we've fetched the config, be evil and use the getter to inject the focus
// into the client (see above XXX).
if (focus) {
client.setLivekitServiceURL(livekit.livekit_service_url);
}
await client.startClient();
resolve(client);
})();
});
return { api, lazyActions, client: clientPromise };
} else {