Merge remote-tracking branch 'origin/livekit' into dbkr/matrixrtcsession

This commit is contained in:
David Baker
2023-08-16 18:53:00 +01:00
20 changed files with 334 additions and 150 deletions

View File

@@ -67,7 +67,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=sha,format=short,event=branch type=sha,format=short,event=branch
type=semver,pattern={{version}} type=semver,pattern=v{{version}}
type=raw,value=latest-ci,enable={{is_default_branch}} type=raw,value=latest-ci,enable={{is_default_branch}}
type=raw,value=latest-ci_${{steps.current-time.outputs.unix_time}},enable={{is_default_branch}} type=raw,value=latest-ci_${{steps.current-time.outputs.unix_time}},enable={{is_default_branch}}

View File

@@ -75,7 +75,6 @@
"Not registered yet? <2>Create an account</2>": "Not registered yet? <2>Create an account</2>", "Not registered yet? <2>Create an account</2>": "Not registered yet? <2>Create an account</2>",
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>", "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>",
"Password": "Password", "Password": "Password",
"Password (if none, E2EE is disabled)": "Password (if none, E2EE is disabled)",
"Passwords must match": "Passwords must match", "Passwords must match": "Passwords must match",
"Profile": "Profile", "Profile": "Profile",
"Recaptcha dismissed": "Recaptcha dismissed", "Recaptcha dismissed": "Recaptcha dismissed",
@@ -106,6 +105,7 @@
"Thanks, we received your feedback!": "Thanks, we received your feedback!", "Thanks, we received your feedback!": "Thanks, we received your feedback!",
"Thanks!": "Thanks!", "Thanks!": "Thanks!",
"This call already exists, would you like to join?": "This call already exists, would you like to join?", "This call already exists, would you like to join?": "This call already exists, would you like to join?",
"This call is not end-to-end encrypted.": "This call is not end-to-end encrypted.",
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)</12>": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)</12>", "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)</12>": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)</12>",
"Turn off camera": "Turn off camera", "Turn off camera": "Turn off camera",
"Turn on camera": "Turn on camera", "Turn on camera": "Turn on camera",

View File

@@ -19,8 +19,12 @@ import { Trans } from "react-i18next";
import { Banner } from "./Banner"; import { Banner } from "./Banner";
import styles from "./E2EEBanner.module.css"; import styles from "./E2EEBanner.module.css";
import { ReactComponent as LockOffIcon } from "./icons/LockOff.svg"; import { ReactComponent as LockOffIcon } from "./icons/LockOff.svg";
import { useEnableE2EE } from "./settings/useSetting";
export const E2EEBanner = () => { export const E2EEBanner = () => {
const [e2eeEnabled] = useEnableE2EE();
if (e2eeEnabled) return null;
return ( return (
<Banner> <Banner>
<div className={styles.e2eeBanner}> <div className={styles.e2eeBanner}>

View File

@@ -26,10 +26,7 @@ import { TooltipTrigger } from "./Tooltip";
export const E2EELock = () => { export const E2EELock = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const tooltip = useCallback( const tooltip = useCallback(
() => () => t("This call is not end-to-end encrypted."),
t(
"Element Call is temporarily not end-to-end encrypted while we test scalability."
),
[t] [t]
); );

View File

@@ -19,6 +19,8 @@ import { useLocation } from "react-router-dom";
import { Config } from "./config/Config"; import { Config } from "./config/Config";
export const PASSWORD_STRING = "password=";
interface UrlParams { interface UrlParams {
roomAlias: string | null; roomAlias: string | null;
roomId: string | null; roomId: string | null;
@@ -86,6 +88,10 @@ interface UrlParams {
* user's homeserver doesn't provide any. * user's homeserver doesn't provide any.
*/ */
allowIceFallback: boolean; allowIceFallback: boolean;
/**
* E2EE password
*/
password: string | null;
} }
/** /**
@@ -102,9 +108,12 @@ export const getUrlParams = (
pathname = window.location.pathname, pathname = window.location.pathname,
hash = window.location.hash hash = window.location.hash
): UrlParams => { ): UrlParams => {
// This is legacy code - we're moving away from using aliases
let roomAlias: string | null = null; let roomAlias: string | null = null;
if (!ignoreRoomAlias) { if (!ignoreRoomAlias) {
if (hash === "") { // Here we handle the beginning of the alias and make sure it starts with a
// "#"
if (hash === "" || hash.startsWith("#?")) {
roomAlias = pathname.substring(1); // Strip the "/" roomAlias = pathname.substring(1); // Strip the "/"
// Delete "/room/", if present // Delete "/room/", if present
@@ -119,17 +128,17 @@ export const getUrlParams = (
roomAlias = hash; roomAlias = hash;
} }
// Add server part, if not present
if (!roomAlias.includes(":")) {
roomAlias = `${roomAlias}:${Config.defaultServerName()}`;
}
// Delete "?" and what comes afterwards // Delete "?" and what comes afterwards
roomAlias = roomAlias.split("?")[0]; roomAlias = roomAlias.split("?")[0];
// Make roomAlias undefined, if empty
if (roomAlias.length <= 1) { if (roomAlias.length <= 1) {
// Make roomAlias is null, if it only is a "#"
roomAlias = null; roomAlias = null;
} else {
// Add server part, if not present
if (!roomAlias.includes(":")) {
roomAlias = `${roomAlias}:${Config.defaultServerName()}`;
}
} }
} }
@@ -164,6 +173,7 @@ export const getUrlParams = (
return { return {
roomAlias, roomAlias,
roomId, roomId,
password: getParam("password"),
viaServers: getAllParams("via"), viaServers: getAllParams("via"),
isEmbedded: hasParam("embed"), isEmbedded: hasParam("embed"),
preload: hasParam("preload"), preload: hasParam("preload"),

View File

@@ -20,7 +20,7 @@ import { MatrixClient } from "matrix-js-sdk";
import { Buffer } from "buffer"; import { Buffer } from "buffer";
import { widget } from "../widget"; import { widget } from "../widget";
import { getSetting, setSetting, settingsBus } from "../settings/useSetting"; import { getSetting, setSetting, getSettingKey } from "../settings/useSetting";
import { import {
CallEndedTracker, CallEndedTracker,
CallStartedTracker, CallStartedTracker,
@@ -34,6 +34,7 @@ import {
} from "./PosthogEvents"; } from "./PosthogEvents";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams"; import { getUrlParams } from "../UrlParams";
import { localStorageBus } from "../useLocalStorage";
/* Posthog analytics tracking. /* Posthog analytics tracking.
* *
@@ -413,7 +414,7 @@ export class PosthogAnalytics {
// * When the user changes their preferences on this device // * When the user changes their preferences on this device
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings // Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes) // won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
settingsBus.on("opt-in-analytics", (optInAnalytics) => { localStorageBus.on(getSettingKey("opt-in-analytics"), (optInAnalytics) => {
this.updateAnonymityAndIdentifyUser(optInAnalytics); this.updateAnonymityAndIdentifyUser(optInAnalytics);
}); });
} }

View File

@@ -0,0 +1,81 @@
/*
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 { useEffect, useMemo } from "react";
import { useEnableE2EE } from "../settings/useSetting";
import { useLocalStorage } from "../useLocalStorage";
import { useClient } from "../ClientContext";
import { PASSWORD_STRING, useUrlParams } from "../UrlParams";
export const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
`room-shared-key-${roomId}`;
export const useInternalRoomSharedKey = (
roomId: string
): [string | null, (value: string) => void] => {
const key = useMemo(() => getRoomSharedKeyLocalStorageKey(roomId), [roomId]);
const [e2eeEnabled] = useEnableE2EE();
const [roomSharedKey, setRoomSharedKey] = useLocalStorage(key);
return [e2eeEnabled ? roomSharedKey : null, setRoomSharedKey];
};
export const useRoomSharedKey = (roomId: string): string | null => {
return useInternalRoomSharedKey(roomId)[0];
};
export const useManageRoomSharedKey = (roomId: string): string | null => {
const { password } = useUrlParams();
const [e2eeSharedKey, setE2EESharedKey] = useInternalRoomSharedKey(roomId);
useEffect(() => {
if (!password) return;
if (password === "") return;
if (password === e2eeSharedKey) return;
setE2EESharedKey(password);
}, [password, e2eeSharedKey, setE2EESharedKey]);
useEffect(() => {
const hash = location.hash;
if (!hash.includes("?")) return;
if (!hash.includes(PASSWORD_STRING)) return;
if (password !== e2eeSharedKey) return;
const [hashStart, passwordStart] = hash.split(PASSWORD_STRING);
const hashEnd = passwordStart.split("&")[1];
location.replace((hashStart ?? "") + (hashEnd ?? ""));
}, [password, e2eeSharedKey]);
return e2eeSharedKey;
};
export const useIsRoomE2EE = (roomId: string): boolean | null => {
const client = useClient();
const room = useMemo(
() => client.client?.getRoom(roomId) ?? null,
[roomId, client.client]
);
const isE2EE = useMemo(
() => (room ? !room?.getCanonicalAlias() : null),
[room]
);
return isE2EE;
};

View File

@@ -23,8 +23,9 @@ import { Facepile } from "../Facepile";
import { Avatar, Size } from "../Avatar"; import { Avatar, Size } from "../Avatar";
import styles from "./CallList.module.css"; import styles from "./CallList.module.css";
import { getRoomUrl } from "../matrix-utils"; import { getRoomUrl } from "../matrix-utils";
import { Body, Caption } from "../typography/Typography"; import { Body } from "../typography/Typography";
import { GroupCallRoom } from "./useGroupCallRooms"; import { GroupCallRoom } from "./useGroupCallRooms";
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
interface CallListProps { interface CallListProps {
rooms: GroupCallRoom[]; rooms: GroupCallRoom[];
@@ -35,13 +36,13 @@ export function CallList({ rooms, client, disableFacepile }: CallListProps) {
return ( return (
<> <>
<div className={styles.callList}> <div className={styles.callList}>
{rooms.map(({ roomAlias, roomName, avatarUrl, participants }) => ( {rooms.map(({ room, roomAlias, roomName, avatarUrl, participants }) => (
<CallTile <CallTile
key={roomAlias} key={roomAlias}
client={client} client={client}
name={roomName} name={roomName}
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
roomAlias={roomAlias} roomId={room.roomId}
participants={participants} participants={participants}
disableFacepile={disableFacepile} disableFacepile={disableFacepile}
/> />
@@ -59,7 +60,7 @@ export function CallList({ rooms, client, disableFacepile }: CallListProps) {
interface CallTileProps { interface CallTileProps {
name: string; name: string;
avatarUrl: string; avatarUrl: string;
roomAlias: string; roomId: string;
participants: RoomMember[]; participants: RoomMember[];
client: MatrixClient; client: MatrixClient;
disableFacepile?: boolean; disableFacepile?: boolean;
@@ -67,17 +68,16 @@ interface CallTileProps {
function CallTile({ function CallTile({
name, name,
avatarUrl, avatarUrl,
roomAlias, roomId,
participants, participants,
client, client,
disableFacepile, disableFacepile,
}: CallTileProps) { }: CallTileProps) {
const roomSharedKey = useRoomSharedKey(roomId);
return ( return (
<div className={styles.callTile}> <div className={styles.callTile}>
<Link <Link to={`/room/#?roomId=${roomId}`} className={styles.callTileLink}>
to={`/${roomAlias.substring(1).split(":")[0]}`}
className={styles.callTileLink}
>
<Avatar <Avatar
size={Size.LG} size={Size.LG}
bgKey={name} bgKey={name}
@@ -89,7 +89,6 @@ function CallTile({
<Body overflowEllipsis fontWeight="semiBold"> <Body overflowEllipsis fontWeight="semiBold">
{name} {name}
</Body> </Body>
<Caption overflowEllipsis>{getRoomUrl(roomAlias)}</Caption>
{participants && !disableFacepile && ( {participants && !disableFacepile && (
<Facepile <Facepile
className={styles.facePile} className={styles.facePile}
@@ -103,7 +102,7 @@ function CallTile({
<CopyButton <CopyButton
className={styles.copyButton} className={styles.copyButton}
variant="icon" variant="icon"
value={getRoomUrl(roomAlias)} value={getRoomUrl(roomId, roomSharedKey ?? undefined)}
/> />
</div> </div>
); );

View File

@@ -17,6 +17,7 @@ limitations under the License.
import { useState, useCallback, FormEvent, FormEventHandler } from "react"; import { useState, useCallback, FormEvent, FormEventHandler } from "react";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -37,9 +38,11 @@ import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { Caption, Title } from "../typography/Typography"; import { Caption, Title } from "../typography/Typography";
import { Form } from "../form/Form"; import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown"; import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import { useOptInAnalytics } from "../settings/useSetting"; import { useEnableE2EE, useOptInAnalytics } from "../settings/useSetting";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { E2EEBanner } from "../E2EEBanner"; import { E2EEBanner } from "../E2EEBanner";
import { setLocalStorageItem } from "../useLocalStorage";
import { getRoomSharedKeyLocalStorageKey } from "../e2ee/sharedKeyManagement";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
@@ -54,6 +57,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
const history = useHistory(); const history = useHistory();
const { t } = useTranslation(); const { t } = useTranslation();
const { modalState, modalProps } = useModalTriggerState(); const { modalState, modalProps } = useModalTriggerState();
const [e2eeEnabled] = useEnableE2EE();
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback( const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
(e: FormEvent) => { (e: FormEvent) => {
@@ -70,16 +74,23 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
setError(undefined); setError(undefined);
setLoading(true); setLoading(true);
const [roomAlias] = await createRoom(client, roomName, ptt); const roomId = (
await createRoom(client, roomName, ptt, e2eeEnabled ?? false)
)[1];
if (roomAlias) { if (e2eeEnabled) {
history.push(`/${roomAlias.substring(1).split(":")[0]}`); setLocalStorageItem(
getRoomSharedKeyLocalStorageKey(roomId),
randomString(32)
);
} }
history.push(`/room/#?roomId=${roomId}`);
} }
submit().catch((error) => { submit().catch((error) => {
if (error.errcode === "M_ROOM_IN_USE") { if (error.errcode === "M_ROOM_IN_USE") {
setExistingRoomId(roomAliasLocalpartFromRoomName(roomName)); setExistingAlias(roomAliasLocalpartFromRoomName(roomName));
setLoading(false); setLoading(false);
setError(undefined); setError(undefined);
modalState.open(); modalState.open();
@@ -90,15 +101,15 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
} }
}); });
}, },
[client, history, modalState, callType] [client, history, modalState, callType, e2eeEnabled]
); );
const recentRooms = useGroupCallRooms(client); const recentRooms = useGroupCallRooms(client);
const [existingRoomId, setExistingRoomId] = useState<string>(); const [existingAlias, setExistingAlias] = useState<string>();
const onJoinExistingRoom = useCallback(() => { const onJoinExistingRoom = useCallback(() => {
history.push(`/${existingRoomId}`); history.push(`/${existingAlias}`);
}, [history, existingRoomId]); }, [history, existingAlias]);
const callNameLabel = const callNameLabel =
callType === CallType.Video callType === CallType.Video

View File

@@ -40,9 +40,11 @@ import styles from "./UnauthenticatedView.module.css";
import commonStyles from "./common.module.css"; import commonStyles from "./common.module.css";
import { generateRandomName } from "../auth/generateRandomName"; import { generateRandomName } from "../auth/generateRandomName";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { useOptInAnalytics } from "../settings/useSetting"; import { useEnableE2EE, useOptInAnalytics } from "../settings/useSetting";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { E2EEBanner } from "../E2EEBanner"; import { E2EEBanner } from "../E2EEBanner";
import { getRoomSharedKeyLocalStorageKey } from "../e2ee/sharedKeyManagement";
import { setLocalStorageItem } from "../useLocalStorage";
export const UnauthenticatedView: FC = () => { export const UnauthenticatedView: FC = () => {
const { setClient } = useClient(); const { setClient } = useClient();
@@ -58,6 +60,8 @@ export const UnauthenticatedView: FC = () => {
const history = useHistory(); const history = useHistory();
const { t } = useTranslation(); const { t } = useTranslation();
const [e2eeEnabled] = useEnableE2EE();
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback( const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
(e) => { (e) => {
e.preventDefault(); e.preventDefault();
@@ -79,9 +83,18 @@ export const UnauthenticatedView: FC = () => {
true true
); );
let roomAlias: string; let roomId: string;
try { try {
[roomAlias] = await createRoom(client, roomName, ptt); roomId = (
await createRoom(client, roomName, ptt, e2eeEnabled ?? false)
)[1];
if (e2eeEnabled) {
setLocalStorageItem(
getRoomSharedKeyLocalStorageKey(roomId),
randomString(32)
);
}
} catch (error) { } catch (error) {
if (!setClient) { if (!setClient) {
throw error; throw error;
@@ -110,7 +123,7 @@ export const UnauthenticatedView: FC = () => {
} }
setClient({ client, session }); setClient({ client, session });
history.push(`/${roomAlias.substring(1).split(":")[0]}`); history.push(`/room/#?roomId=${roomId}`);
} }
submit().catch((error) => { submit().catch((error) => {
@@ -120,7 +133,16 @@ export const UnauthenticatedView: FC = () => {
reset(); reset();
}); });
}, },
[register, reset, execute, history, callType, modalState, setClient] [
register,
reset,
execute,
history,
callType,
modalState,
setClient,
e2eeEnabled,
]
); );
const callNameLabel = const callNameLabel =

View File

@@ -89,8 +89,7 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
const groupCalls = client.groupCallEventHandler.groupCalls.values(); const groupCalls = client.groupCallEventHandler.groupCalls.values();
const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room); 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, rooms);
const sortedRooms = sortRooms(client, filteredRooms);
const items = sortedRooms.map((room) => { const items = sortedRooms.map((room) => {
const groupCall = client.getGroupCallForRoom(room.roomId)!; const groupCall = client.getGroupCallForRoom(room.roomId)!;

View File

@@ -32,7 +32,7 @@ import {
import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room"; import type { Room } from "matrix-js-sdk/src/models/room";
import IndexedDBWorker from "./IndexedDBWorker?worker"; import IndexedDBWorker from "./IndexedDBWorker?worker";
import { getUrlParams } from "./UrlParams"; import { getUrlParams, PASSWORD_STRING } from "./UrlParams";
import { loadOlm } from "./olm"; import { loadOlm } from "./olm";
import { Config } from "./config/Config"; import { Config } from "./config/Config";
@@ -272,14 +272,15 @@ export function isLocalRoomId(roomId: string, client: MatrixClient): boolean {
export async function createRoom( export async function createRoom(
client: MatrixClient, client: MatrixClient,
name: string, name: string,
ptt: boolean ptt: boolean,
e2ee: boolean
): Promise<[string, string]> { ): Promise<[string, string]> {
logger.log(`Creating room for group call`); logger.log(`Creating room for group call`);
const createPromise = client.createRoom({ const createPromise = client.createRoom({
visibility: Visibility.Private, visibility: Visibility.Private,
preset: Preset.PublicChat, preset: Preset.PublicChat,
name, name,
room_alias_name: roomAliasLocalpartFromRoomName(name), room_alias_name: e2ee ? undefined : roomAliasLocalpartFromRoomName(name),
power_level_content_override: { power_level_content_override: {
invite: 100, invite: 100,
kick: 100, kick: 100,
@@ -341,15 +342,16 @@ export async function createRoom(
return [fullAliasFromRoomName(name, client), result.room_id]; return [fullAliasFromRoomName(name, client), result.room_id];
} }
// Returns a URL to that will load Element Call with the given room /**
export function getRoomUrl(roomIdOrAlias: string): string { * Returns a URL to that will load Element Call with the given room
if (roomIdOrAlias.startsWith("#")) { * @param roomId of the room
return `${window.location.protocol}//${window.location.host}/${ * @param password
roomIdOrAlias.substring(1).split(":")[0] * @returns
}`; */
} else { export function getRoomUrl(roomId: string, password?: string): string {
return `${window.location.protocol}//${window.location.host}/room?roomId=${roomIdOrAlias}`; return `${window.location.protocol}//${
} window.location.host
}/room/#?roomId=${roomId}${password ? "&" + PASSWORD_STRING + password : ""}`;
} }
export function getAvatarUrl( export function getAvatarUrl(

View File

@@ -31,7 +31,6 @@ import { MatrixInfo } from "./VideoPreview";
import { CallEndedView } from "./CallEndedView"; import { CallEndedView } from "./CallEndedView";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useProfile } from "../profile/useProfile"; import { useProfile } from "../profile/useProfile";
import { E2EEConfig } from "../livekit/useLiveKit";
import { findDeviceByName } from "../media-utils"; import { findDeviceByName } from "../media-utils";
//import { OpenIDLoader } from "../livekit/OpenIDLoader"; //import { OpenIDLoader } from "../livekit/OpenIDLoader";
import { ActiveCall } from "./InCallView"; import { ActiveCall } from "./InCallView";
@@ -41,6 +40,11 @@ import { LivekitFocus } from "../livekit/LivekitFocus";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers"; import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState"; import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
import {
useManageRoomSharedKey,
useIsRoomE2EE,
} from "../e2ee/sharedKeyManagement";
import { useEnableE2EE } from "../settings/useSetting";
declare global { declare global {
interface Window { interface Window {
@@ -78,6 +82,9 @@ export function GroupCallView({
const memberships = useMatrixRTCSessionMemberships(rtcSession); const memberships = useMatrixRTCSessionMemberships(rtcSession);
const isJoined = useMatrixRTCSessionJoinState(rtcSession); const isJoined = useMatrixRTCSessionJoinState(rtcSession);
const e2eeSharedKey = useManageRoomSharedKey(groupCall.room.roomId);
const isRoomE2EE = useIsRoomE2EE(groupCall.room.roomId);
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
@@ -243,8 +250,11 @@ export function GroupCallView({
} }
}, [isJoined, rtcSession]); }, [isJoined, rtcSession]);
const [e2eeConfig, setE2EEConfig] = useState<E2EEConfig | undefined>( const [e2eeEnabled] = useEnableE2EE();
undefined
const e2eeConfig = useMemo(
() => (e2eeSharedKey ? { sharedKey: e2eeSharedKey } : undefined),
[e2eeSharedKey]
); );
const onReconnect = useCallback(() => { const onReconnect = useCallback(() => {
@@ -266,6 +276,22 @@ export function GroupCallView({
return <ErrorView error={new Error("Call focus is not compatible!")} />; return <ErrorView error={new Error("Call focus is not compatible!")} />;
} }
if (e2eeEnabled && isRoomE2EE && !e2eeSharedKey) {
return (
<ErrorView
error={
new Error(
"No E2EE key provided: please make sure the URL you're using to join this call has been retrieved using the in-app button."
)
}
/>
);
}
if (!e2eeEnabled && isRoomE2EE) {
return <ErrorView error={new Error("You need to enable E2EE to join.")} />;
}
if (isJoined) { if (isJoined) {
return ( return (
/*<OpenIDLoader /*<OpenIDLoader
@@ -325,10 +351,7 @@ export function GroupCallView({
<LobbyView <LobbyView
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
muteStates={muteStates} muteStates={muteStates}
onEnter={(e2eeConfig?: E2EEConfig) => { onEnter={() => enter()}
setE2EEConfig(e2eeConfig);
enterRTCSession(rtcSession);
}}
isEmbedded={isEmbedded} isEmbedded={isEmbedded}
hideHeader={hideHeader} hideHeader={hideHeader}
/> />

View File

@@ -78,6 +78,7 @@ import { useEventEmitterThree } from "../useEvents";
import { useWakeLock } from "../useWakeLock"; import { useWakeLock } from "../useWakeLock";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
import { MuteStates } from "./MuteStates"; import { MuteStates } from "./MuteStates";
import { useIsRoomE2EE } from "../e2ee/sharedKeyManagement";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams // There is currently a bug in Safari our our code with cloning and sending MediaStreams
@@ -133,6 +134,8 @@ export function InCallView({
usePreventScroll(); usePreventScroll();
useWakeLock(); useWakeLock();
const isRoomE2EE = useIsRoomE2EE(rtcSession.room.roomId);
const containerRef1 = useRef<HTMLDivElement | null>(null); const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver }); const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
const boundsValid = bounds.height > 0; const boundsValid = bounds.height > 0;
@@ -408,7 +411,7 @@ export function InCallView({
<Header> <Header>
<LeftNav> <LeftNav>
<RoomHeaderInfo roomName={rtcSession.room.name} /> <RoomHeaderInfo roomName={rtcSession.room.name} />
<E2EELock /> {!isRoomE2EE && <E2EELock />}
</LeftNav> </LeftNav>
<RightNav> <RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} /> <GridLayoutMenu layout={layout} setLayout={setLayout} />
@@ -445,12 +448,7 @@ export function InCallView({
/> />
)} )}
{inviteModalState.isOpen && ( {inviteModalState.isOpen && (
<InviteModal <InviteModal roomId={rtcSession.room.roomId} {...inviteModalProps} />
roomIdOrAlias={
rtcSession.room.getCanonicalAlias() ?? rtcSession.room.roomId
}
{...inviteModalProps}
/>
)} )}
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2022 New Vector Ltd Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -21,13 +21,15 @@ import { Modal, ModalContent, ModalProps } from "../Modal";
import { CopyButton } from "../button"; import { CopyButton } from "../button";
import { getRoomUrl } from "../matrix-utils"; import { getRoomUrl } from "../matrix-utils";
import styles from "./InviteModal.module.css"; import styles from "./InviteModal.module.css";
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
interface Props extends Omit<ModalProps, "title" | "children"> { 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(); const { t } = useTranslation();
const roomSharedKey = useRoomSharedKey(roomId);
return ( return (
<Modal <Modal
@@ -40,7 +42,7 @@ export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => {
<p>{t("Copy and share this call link")}</p> <p>{t("Copy and share this call link")}</p>
<CopyButton <CopyButton
className={styles.copyButton} className={styles.copyButton}
value={getRoomUrl(roomIdOrAlias)} value={getRoomUrl(roomId, roomSharedKey ?? undefined)}
data-testid="modal_inviteLink" data-testid="modal_inviteLink"
/> />
</ModalContent> </ModalContent>

View File

@@ -14,14 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { import { useRef, useEffect, FC } from "react";
useRef,
useEffect,
useState,
useCallback,
ChangeEvent,
FC,
} from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import styles from "./LobbyView.module.css"; import styles from "./LobbyView.module.css";
@@ -32,15 +25,13 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { Body, Link } from "../typography/Typography"; import { Body, Link } from "../typography/Typography";
import { useLocationNavigation } from "../useLocationNavigation"; import { useLocationNavigation } from "../useLocationNavigation";
import { MatrixInfo, VideoPreview } from "./VideoPreview"; import { MatrixInfo, VideoPreview } from "./VideoPreview";
import { E2EEConfig } from "../livekit/useLiveKit";
import { InputField } from "../input/Input";
import { useEnableE2EE } from "../settings/useSetting";
import { MuteStates } from "./MuteStates"; import { MuteStates } from "./MuteStates";
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
interface Props { interface Props {
matrixInfo: MatrixInfo; matrixInfo: MatrixInfo;
muteStates: MuteStates; muteStates: MuteStates;
onEnter: (e2eeConfig?: E2EEConfig) => void; onEnter: () => void;
isEmbedded: boolean; isEmbedded: boolean;
hideHeader: boolean; hideHeader: boolean;
} }
@@ -53,10 +44,9 @@ export const LobbyView: FC<Props> = ({
hideHeader, hideHeader,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const roomSharedKey = useRoomSharedKey(matrixInfo.roomId);
useLocationNavigation(); useLocationNavigation();
const [enableE2EE] = useEnableE2EE();
const joinCallButtonRef = useRef<HTMLButtonElement>(null); const joinCallButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => { useEffect(() => {
if (joinCallButtonRef.current) { if (joinCallButtonRef.current) {
@@ -64,18 +54,6 @@ export const LobbyView: FC<Props> = ({
} }
}, [joinCallButtonRef]); }, [joinCallButtonRef]);
const [e2eeSharedKey, setE2EESharedKey] = useState<string | undefined>(
undefined
);
const onE2EESharedKeyChanged = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setE2EESharedKey(value === "" ? undefined : value);
},
[setE2EESharedKey]
);
return ( return (
<div className={styles.room}> <div className={styles.room}>
{!hideHeader && ( {!hideHeader && (
@@ -91,25 +69,12 @@ export const LobbyView: FC<Props> = ({
<div className={styles.joinRoom}> <div className={styles.joinRoom}>
<div className={styles.joinRoomContent}> <div className={styles.joinRoomContent}>
<VideoPreview matrixInfo={matrixInfo} muteStates={muteStates} /> <VideoPreview matrixInfo={matrixInfo} muteStates={muteStates} />
{enableE2EE && (
<InputField
className={styles.passwordField}
label={t("Password (if none, E2EE is disabled)")}
type="text"
onChange={onE2EESharedKeyChanged}
value={e2eeSharedKey}
/>
)}
<Trans> <Trans>
<Button <Button
ref={joinCallButtonRef} ref={joinCallButtonRef}
className={styles.copyButton} className={styles.copyButton}
size="lg" size="lg"
onPress={() => onPress={() => onEnter()}
onEnter(
e2eeSharedKey ? { sharedKey: e2eeSharedKey } : undefined
)
}
data-testid="lobby_joinCall" data-testid="lobby_joinCall"
> >
Join call now Join call now
@@ -117,7 +82,7 @@ export const LobbyView: FC<Props> = ({
<Body>Or</Body> <Body>Or</Body>
<CopyButton <CopyButton
variant="secondaryCopy" variant="secondaryCopy"
value={getRoomUrl(matrixInfo.roomAlias ?? matrixInfo.roomId)} value={getRoomUrl(matrixInfo.roomId, roomSharedKey ?? undefined)}
className={styles.copyButton} className={styles.copyButton}
copiedMessage={t("Call link copied")} copiedMessage={t("Call link copied")}
data-testid="lobby_inviteLink" data-testid="lobby_inviteLink"

View File

@@ -20,10 +20,14 @@ import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { SyncState } from "matrix-js-sdk/src/sync"; import { SyncState } from "matrix-js-sdk/src/sync";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { randomString } from "matrix-js-sdk/src/randomstring";
import type { Room } from "matrix-js-sdk/src/models/room"; import type { Room } from "matrix-js-sdk/src/models/room";
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { setLocalStorageItem } from "../useLocalStorage";
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils"; import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
import { useEnableE2EE } from "../settings/useSetting";
import { getRoomSharedKeyLocalStorageKey } from "../e2ee/sharedKeyManagement";
export type GroupCallLoaded = { export type GroupCallLoaded = {
kind: "loaded"; kind: "loaded";
@@ -58,6 +62,8 @@ export const useLoadGroupCall = (
const { t } = useTranslation(); const { t } = useTranslation();
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" }); const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
const [e2eeEnabled] = useEnableE2EE();
useEffect(() => { useEffect(() => {
const fetchOrCreateRoom = async (): Promise<Room> => { const fetchOrCreateRoom = async (): Promise<Room> => {
try { try {
@@ -95,8 +101,17 @@ export const useLoadGroupCall = (
const [, roomId] = await createRoom( const [, roomId] = await createRoom(
client, client,
roomNameFromRoomId(roomIdOrAlias), roomNameFromRoomId(roomIdOrAlias),
createPtt createPtt,
e2eeEnabled ?? false
); );
if (e2eeEnabled) {
setLocalStorageItem(
getRoomSharedKeyLocalStorageKey(roomId),
randomString(32)
);
}
// likewise, wait for the room // likewise, wait for the room
await client.waitUntilRoomReadyForGroupCalls(roomId); await client.waitUntilRoomReadyForGroupCalls(roomId);
return client.getRoom(roomId)!; return client.getRoom(roomId)!;
@@ -136,7 +151,7 @@ export const useLoadGroupCall = (
.then(fetchOrCreateGroupCall) .then(fetchOrCreateGroupCall)
.then((rtcSession) => setState({ kind: "loaded", rtcSession })) .then((rtcSession) => setState({ kind: "loaded", rtcSession }))
.catch((error) => setState({ kind: "failed", error })); .catch((error) => setState({ kind: "failed", error }));
}, [client, roomIdOrAlias, viaServers, createPtt, t]); }, [client, roomIdOrAlias, viaServers, createPtt, t, e2eeEnabled]);
return state; return state;
}; };

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2022 New Vector Ltd Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -14,59 +14,49 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { EventEmitter } from "events"; import { useCallback, useMemo } from "react";
import { useMemo, useState, useEffect, useCallback } from "react";
import { isE2EESupported } from "livekit-client"; import { isE2EESupported } from "livekit-client";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import {
getLocalStorageItem,
setLocalStorageItem,
useLocalStorage,
} from "../useLocalStorage";
type Setting<T> = [T, (value: T) => void]; type Setting<T> = [T, (value: T) => void];
type DisableableSetting<T> = [T, ((value: T) => void) | null]; type DisableableSetting<T> = [T, ((value: T) => void) | null];
// Bus to notify other useSetting consumers when a setting is changed export const getSettingKey = (name: string): string => {
export const settingsBus = new EventEmitter();
const getSettingKey = (name: string): string => {
return `matrix-setting-${name}`; return `matrix-setting-${name}`;
}; };
// Like useState, but reads from and persists the value to localStorage // Like useState, but reads from and persists the value to localStorage
const useSetting = <T>(name: string, defaultValue: T): Setting<T> => { export const useSetting = <T>(name: string, defaultValue: T): Setting<T> => {
const key = useMemo(() => getSettingKey(name), [name]); const key = useMemo(() => getSettingKey(name), [name]);
const [value, setValue] = useState<T>(() => { const [item, setItem] = useLocalStorage(key);
const item = localStorage.getItem(key);
return item == null ? defaultValue : JSON.parse(item);
});
useEffect(() => { const value = useMemo(
settingsBus.on(name, setValue); () => (item == null ? defaultValue : JSON.parse(item)),
return () => { [item, defaultValue]
settingsBus.off(name, setValue); );
}; const setValue = useCallback(
}, [name, setValue]); (value: T) => {
setItem(JSON.stringify(value));
},
[setItem]
);
return [ return [value, setValue];
value,
useCallback(
(newValue: T) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
settingsBus.emit(name, newValue);
},
[name, key, setValue]
),
];
}; };
export const getSetting = <T>(name: string, defaultValue: T): T => { export const getSetting = <T>(name: string, defaultValue: T): T => {
const item = localStorage.getItem(getSettingKey(name)); const item = getLocalStorageItem(getSettingKey(name));
return item === null ? defaultValue : JSON.parse(item); return item === null ? defaultValue : JSON.parse(item);
}; };
export const setSetting = <T>(name: string, newValue: T) => { export const setSetting = <T>(name: string, newValue: T) =>
localStorage.setItem(getSettingKey(name), JSON.stringify(newValue)); setLocalStorageItem(getSettingKey(name), JSON.stringify(newValue));
settingsBus.emit(name, newValue);
};
const canEnableSpatialAudio = () => { const canEnableSpatialAudio = () => {
const { userAgent } = navigator; const { userAgent } = navigator;

59
src/useLocalStorage.ts Normal file
View File

@@ -0,0 +1,59 @@
/*
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 EventEmitter from "events";
import { useCallback, useEffect, useState } from "react";
type LocalStorageItem = ReturnType<typeof localStorage.getItem>;
// Bus to notify other useLocalStorage consumers when an item is changed
export const localStorageBus = new EventEmitter();
// Like useState, but reads from and persists the value to localStorage
export const useLocalStorage = (
key: string
): [LocalStorageItem, (value: string) => void] => {
const [value, setValue] = useState<LocalStorageItem>(() =>
localStorage.getItem(key)
);
useEffect(() => {
localStorageBus.on(key, setValue);
return () => {
localStorageBus.off(key, setValue);
};
}, [key, setValue]);
return [
value,
useCallback(
(newValue: string) => {
setValue(newValue);
localStorage.setItem(key, newValue);
localStorageBus.emit(key, newValue);
},
[key, setValue]
),
];
};
export const getLocalStorageItem = (key: string): LocalStorageItem =>
localStorage.getItem(key);
export const setLocalStorageItem = (key: string, value: string): void => {
localStorage.setItem(key, value);
localStorageBus.emit(key, value);
};

View File

@@ -34,7 +34,13 @@ describe("CallList", () => {
it("should show room", async () => { it("should show room", async () => {
const rooms = [ const rooms = [
{ roomName: "Room #1", roomAlias: "#room-name:server.org" }, {
roomName: "Room #1",
roomAlias: "#room-name:server.org",
room: {
roomId: "!roomId",
},
},
] as GroupCallRoom[]; ] as GroupCallRoom[];
const result = renderComponent(rooms); const result = renderComponent(rooms);