Merge remote-tracking branch 'origin/livekit' into dbkr/matrixrtcsession
This commit is contained in:
2
.github/workflows/publish.yaml
vendored
2
.github/workflows/publish.yaml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
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_${{steps.current-time.outputs.unix_time}},enable={{is_default_branch}}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"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>",
|
||||
"Password": "Password",
|
||||
"Password (if none, E2EE is disabled)": "Password (if none, E2EE is disabled)",
|
||||
"Passwords must match": "Passwords must match",
|
||||
"Profile": "Profile",
|
||||
"Recaptcha dismissed": "Recaptcha dismissed",
|
||||
@@ -106,6 +105,7 @@
|
||||
"Thanks, we received your feedback!": "Thanks, we received your feedback!",
|
||||
"Thanks!": "Thanks!",
|
||||
"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>",
|
||||
"Turn off camera": "Turn off camera",
|
||||
"Turn on camera": "Turn on camera",
|
||||
|
||||
@@ -19,8 +19,12 @@ import { Trans } from "react-i18next";
|
||||
import { Banner } from "./Banner";
|
||||
import styles from "./E2EEBanner.module.css";
|
||||
import { ReactComponent as LockOffIcon } from "./icons/LockOff.svg";
|
||||
import { useEnableE2EE } from "./settings/useSetting";
|
||||
|
||||
export const E2EEBanner = () => {
|
||||
const [e2eeEnabled] = useEnableE2EE();
|
||||
if (e2eeEnabled) return null;
|
||||
|
||||
return (
|
||||
<Banner>
|
||||
<div className={styles.e2eeBanner}>
|
||||
|
||||
@@ -26,10 +26,7 @@ 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("This call is not end-to-end encrypted."),
|
||||
[t]
|
||||
);
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import { useLocation } from "react-router-dom";
|
||||
|
||||
import { Config } from "./config/Config";
|
||||
|
||||
export const PASSWORD_STRING = "password=";
|
||||
|
||||
interface UrlParams {
|
||||
roomAlias: string | null;
|
||||
roomId: string | null;
|
||||
@@ -86,6 +88,10 @@ interface UrlParams {
|
||||
* user's homeserver doesn't provide any.
|
||||
*/
|
||||
allowIceFallback: boolean;
|
||||
/**
|
||||
* E2EE password
|
||||
*/
|
||||
password: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,9 +108,12 @@ export const getUrlParams = (
|
||||
pathname = window.location.pathname,
|
||||
hash = window.location.hash
|
||||
): UrlParams => {
|
||||
// This is legacy code - we're moving away from using aliases
|
||||
let roomAlias: string | null = null;
|
||||
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 "/"
|
||||
|
||||
// Delete "/room/", if present
|
||||
@@ -119,17 +128,17 @@ export const getUrlParams = (
|
||||
roomAlias = hash;
|
||||
}
|
||||
|
||||
// Add server part, if not present
|
||||
if (!roomAlias.includes(":")) {
|
||||
roomAlias = `${roomAlias}:${Config.defaultServerName()}`;
|
||||
}
|
||||
|
||||
// Delete "?" and what comes afterwards
|
||||
roomAlias = roomAlias.split("?")[0];
|
||||
|
||||
// Make roomAlias undefined, if empty
|
||||
if (roomAlias.length <= 1) {
|
||||
// Make roomAlias is null, if it only is a "#"
|
||||
roomAlias = null;
|
||||
} else {
|
||||
// Add server part, if not present
|
||||
if (!roomAlias.includes(":")) {
|
||||
roomAlias = `${roomAlias}:${Config.defaultServerName()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +173,7 @@ export const getUrlParams = (
|
||||
return {
|
||||
roomAlias,
|
||||
roomId,
|
||||
password: getParam("password"),
|
||||
viaServers: getAllParams("via"),
|
||||
isEmbedded: hasParam("embed"),
|
||||
preload: hasParam("preload"),
|
||||
|
||||
@@ -20,7 +20,7 @@ import { MatrixClient } from "matrix-js-sdk";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
import { widget } from "../widget";
|
||||
import { getSetting, setSetting, settingsBus } from "../settings/useSetting";
|
||||
import { getSetting, setSetting, getSettingKey } from "../settings/useSetting";
|
||||
import {
|
||||
CallEndedTracker,
|
||||
CallStartedTracker,
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "./PosthogEvents";
|
||||
import { Config } from "../config/Config";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { localStorageBus } from "../useLocalStorage";
|
||||
|
||||
/* Posthog analytics tracking.
|
||||
*
|
||||
@@ -413,7 +414,7 @@ export class PosthogAnalytics {
|
||||
// * When the user changes their preferences on this device
|
||||
// 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)
|
||||
settingsBus.on("opt-in-analytics", (optInAnalytics) => {
|
||||
localStorageBus.on(getSettingKey("opt-in-analytics"), (optInAnalytics) => {
|
||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
||||
});
|
||||
}
|
||||
|
||||
81
src/e2ee/sharedKeyManagement.ts
Normal file
81
src/e2ee/sharedKeyManagement.ts
Normal 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;
|
||||
};
|
||||
@@ -23,8 +23,9 @@ import { Facepile } from "../Facepile";
|
||||
import { Avatar, Size } from "../Avatar";
|
||||
import styles from "./CallList.module.css";
|
||||
import { getRoomUrl } from "../matrix-utils";
|
||||
import { Body, Caption } from "../typography/Typography";
|
||||
import { Body } from "../typography/Typography";
|
||||
import { GroupCallRoom } from "./useGroupCallRooms";
|
||||
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
interface CallListProps {
|
||||
rooms: GroupCallRoom[];
|
||||
@@ -35,13 +36,13 @@ export function CallList({ rooms, client, disableFacepile }: CallListProps) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.callList}>
|
||||
{rooms.map(({ roomAlias, roomName, avatarUrl, participants }) => (
|
||||
{rooms.map(({ room, roomAlias, roomName, avatarUrl, participants }) => (
|
||||
<CallTile
|
||||
key={roomAlias}
|
||||
client={client}
|
||||
name={roomName}
|
||||
avatarUrl={avatarUrl}
|
||||
roomAlias={roomAlias}
|
||||
roomId={room.roomId}
|
||||
participants={participants}
|
||||
disableFacepile={disableFacepile}
|
||||
/>
|
||||
@@ -59,7 +60,7 @@ export function CallList({ rooms, client, disableFacepile }: CallListProps) {
|
||||
interface CallTileProps {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
roomAlias: string;
|
||||
roomId: string;
|
||||
participants: RoomMember[];
|
||||
client: MatrixClient;
|
||||
disableFacepile?: boolean;
|
||||
@@ -67,17 +68,16 @@ interface CallTileProps {
|
||||
function CallTile({
|
||||
name,
|
||||
avatarUrl,
|
||||
roomAlias,
|
||||
roomId,
|
||||
participants,
|
||||
client,
|
||||
disableFacepile,
|
||||
}: CallTileProps) {
|
||||
const roomSharedKey = useRoomSharedKey(roomId);
|
||||
|
||||
return (
|
||||
<div className={styles.callTile}>
|
||||
<Link
|
||||
to={`/${roomAlias.substring(1).split(":")[0]}`}
|
||||
className={styles.callTileLink}
|
||||
>
|
||||
<Link to={`/room/#?roomId=${roomId}`} className={styles.callTileLink}>
|
||||
<Avatar
|
||||
size={Size.LG}
|
||||
bgKey={name}
|
||||
@@ -89,7 +89,6 @@ function CallTile({
|
||||
<Body overflowEllipsis fontWeight="semiBold">
|
||||
{name}
|
||||
</Body>
|
||||
<Caption overflowEllipsis>{getRoomUrl(roomAlias)}</Caption>
|
||||
{participants && !disableFacepile && (
|
||||
<Facepile
|
||||
className={styles.facePile}
|
||||
@@ -103,7 +102,7 @@ function CallTile({
|
||||
<CopyButton
|
||||
className={styles.copyButton}
|
||||
variant="icon"
|
||||
value={getRoomUrl(roomAlias)}
|
||||
value={getRoomUrl(roomId, roomSharedKey ?? undefined)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
import { useState, useCallback, FormEvent, FormEventHandler } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
@@ -37,9 +38,11 @@ import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||
import { Caption, Title } from "../typography/Typography";
|
||||
import { Form } from "../form/Form";
|
||||
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
|
||||
import { useOptInAnalytics } from "../settings/useSetting";
|
||||
import { useEnableE2EE, useOptInAnalytics } from "../settings/useSetting";
|
||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||
import { E2EEBanner } from "../E2EEBanner";
|
||||
import { setLocalStorageItem } from "../useLocalStorage";
|
||||
import { getRoomSharedKeyLocalStorageKey } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
@@ -54,6 +57,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const { modalState, modalProps } = useModalTriggerState();
|
||||
const [e2eeEnabled] = useEnableE2EE();
|
||||
|
||||
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
(e: FormEvent) => {
|
||||
@@ -70,16 +74,23 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
|
||||
const [roomAlias] = await createRoom(client, roomName, ptt);
|
||||
const roomId = (
|
||||
await createRoom(client, roomName, ptt, e2eeEnabled ?? false)
|
||||
)[1];
|
||||
|
||||
if (roomAlias) {
|
||||
history.push(`/${roomAlias.substring(1).split(":")[0]}`);
|
||||
if (e2eeEnabled) {
|
||||
setLocalStorageItem(
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
randomString(32)
|
||||
);
|
||||
}
|
||||
|
||||
history.push(`/room/#?roomId=${roomId}`);
|
||||
}
|
||||
|
||||
submit().catch((error) => {
|
||||
if (error.errcode === "M_ROOM_IN_USE") {
|
||||
setExistingRoomId(roomAliasLocalpartFromRoomName(roomName));
|
||||
setExistingAlias(roomAliasLocalpartFromRoomName(roomName));
|
||||
setLoading(false);
|
||||
setError(undefined);
|
||||
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 [existingRoomId, setExistingRoomId] = useState<string>();
|
||||
const [existingAlias, setExistingAlias] = useState<string>();
|
||||
const onJoinExistingRoom = useCallback(() => {
|
||||
history.push(`/${existingRoomId}`);
|
||||
}, [history, existingRoomId]);
|
||||
history.push(`/${existingAlias}`);
|
||||
}, [history, existingAlias]);
|
||||
|
||||
const callNameLabel =
|
||||
callType === CallType.Video
|
||||
|
||||
@@ -40,9 +40,11 @@ import styles from "./UnauthenticatedView.module.css";
|
||||
import commonStyles from "./common.module.css";
|
||||
import { generateRandomName } from "../auth/generateRandomName";
|
||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||
import { useOptInAnalytics } from "../settings/useSetting";
|
||||
import { useEnableE2EE, useOptInAnalytics } from "../settings/useSetting";
|
||||
import { Config } from "../config/Config";
|
||||
import { E2EEBanner } from "../E2EEBanner";
|
||||
import { getRoomSharedKeyLocalStorageKey } from "../e2ee/sharedKeyManagement";
|
||||
import { setLocalStorageItem } from "../useLocalStorage";
|
||||
|
||||
export const UnauthenticatedView: FC = () => {
|
||||
const { setClient } = useClient();
|
||||
@@ -58,6 +60,8 @@ export const UnauthenticatedView: FC = () => {
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [e2eeEnabled] = useEnableE2EE();
|
||||
|
||||
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
@@ -79,9 +83,18 @@ export const UnauthenticatedView: FC = () => {
|
||||
true
|
||||
);
|
||||
|
||||
let roomAlias: string;
|
||||
let roomId: string;
|
||||
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) {
|
||||
if (!setClient) {
|
||||
throw error;
|
||||
@@ -110,7 +123,7 @@ export const UnauthenticatedView: FC = () => {
|
||||
}
|
||||
|
||||
setClient({ client, session });
|
||||
history.push(`/${roomAlias.substring(1).split(":")[0]}`);
|
||||
history.push(`/room/#?roomId=${roomId}`);
|
||||
}
|
||||
|
||||
submit().catch((error) => {
|
||||
@@ -120,7 +133,16 @@ export const UnauthenticatedView: FC = () => {
|
||||
reset();
|
||||
});
|
||||
},
|
||||
[register, reset, execute, history, callType, modalState, setClient]
|
||||
[
|
||||
register,
|
||||
reset,
|
||||
execute,
|
||||
history,
|
||||
callType,
|
||||
modalState,
|
||||
setClient,
|
||||
e2eeEnabled,
|
||||
]
|
||||
);
|
||||
|
||||
const callNameLabel =
|
||||
|
||||
@@ -89,8 +89,7 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
|
||||
|
||||
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 sortedRooms = sortRooms(client, rooms);
|
||||
const items = sortedRooms.map((room) => {
|
||||
const groupCall = client.getGroupCallForRoom(room.roomId)!;
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||
import IndexedDBWorker from "./IndexedDBWorker?worker";
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { getUrlParams, PASSWORD_STRING } from "./UrlParams";
|
||||
import { loadOlm } from "./olm";
|
||||
import { Config } from "./config/Config";
|
||||
|
||||
@@ -272,14 +272,15 @@ export function isLocalRoomId(roomId: string, client: MatrixClient): boolean {
|
||||
export async function createRoom(
|
||||
client: MatrixClient,
|
||||
name: string,
|
||||
ptt: boolean
|
||||
ptt: boolean,
|
||||
e2ee: boolean
|
||||
): Promise<[string, string]> {
|
||||
logger.log(`Creating room for group call`);
|
||||
const createPromise = client.createRoom({
|
||||
visibility: Visibility.Private,
|
||||
preset: Preset.PublicChat,
|
||||
name,
|
||||
room_alias_name: roomAliasLocalpartFromRoomName(name),
|
||||
room_alias_name: e2ee ? undefined : roomAliasLocalpartFromRoomName(name),
|
||||
power_level_content_override: {
|
||||
invite: 100,
|
||||
kick: 100,
|
||||
@@ -341,15 +342,16 @@ export async function createRoom(
|
||||
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 {
|
||||
if (roomIdOrAlias.startsWith("#")) {
|
||||
return `${window.location.protocol}//${window.location.host}/${
|
||||
roomIdOrAlias.substring(1).split(":")[0]
|
||||
}`;
|
||||
} else {
|
||||
return `${window.location.protocol}//${window.location.host}/room?roomId=${roomIdOrAlias}`;
|
||||
}
|
||||
/**
|
||||
* Returns a URL to that will load Element Call with the given room
|
||||
* @param roomId of the room
|
||||
* @param password
|
||||
* @returns
|
||||
*/
|
||||
export function getRoomUrl(roomId: string, password?: string): string {
|
||||
return `${window.location.protocol}//${
|
||||
window.location.host
|
||||
}/room/#?roomId=${roomId}${password ? "&" + PASSWORD_STRING + password : ""}`;
|
||||
}
|
||||
|
||||
export function getAvatarUrl(
|
||||
|
||||
@@ -31,7 +31,6 @@ import { MatrixInfo } from "./VideoPreview";
|
||||
import { CallEndedView } from "./CallEndedView";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { E2EEConfig } from "../livekit/useLiveKit";
|
||||
import { findDeviceByName } from "../media-utils";
|
||||
//import { OpenIDLoader } from "../livekit/OpenIDLoader";
|
||||
import { ActiveCall } from "./InCallView";
|
||||
@@ -41,6 +40,11 @@ import { LivekitFocus } from "../livekit/LivekitFocus";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
|
||||
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
|
||||
import {
|
||||
useManageRoomSharedKey,
|
||||
useIsRoomE2EE,
|
||||
} from "../e2ee/sharedKeyManagement";
|
||||
import { useEnableE2EE } from "../settings/useSetting";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -78,6 +82,9 @@ export function GroupCallView({
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
|
||||
|
||||
const e2eeSharedKey = useManageRoomSharedKey(groupCall.room.roomId);
|
||||
const isRoomE2EE = useIsRoomE2EE(groupCall.room.roomId);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -243,8 +250,11 @@ export function GroupCallView({
|
||||
}
|
||||
}, [isJoined, rtcSession]);
|
||||
|
||||
const [e2eeConfig, setE2EEConfig] = useState<E2EEConfig | undefined>(
|
||||
undefined
|
||||
const [e2eeEnabled] = useEnableE2EE();
|
||||
|
||||
const e2eeConfig = useMemo(
|
||||
() => (e2eeSharedKey ? { sharedKey: e2eeSharedKey } : undefined),
|
||||
[e2eeSharedKey]
|
||||
);
|
||||
|
||||
const onReconnect = useCallback(() => {
|
||||
@@ -266,6 +276,22 @@ export function GroupCallView({
|
||||
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) {
|
||||
return (
|
||||
/*<OpenIDLoader
|
||||
@@ -325,10 +351,7 @@ export function GroupCallView({
|
||||
<LobbyView
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={muteStates}
|
||||
onEnter={(e2eeConfig?: E2EEConfig) => {
|
||||
setE2EEConfig(e2eeConfig);
|
||||
enterRTCSession(rtcSession);
|
||||
}}
|
||||
onEnter={() => enter()}
|
||||
isEmbedded={isEmbedded}
|
||||
hideHeader={hideHeader}
|
||||
/>
|
||||
|
||||
@@ -78,6 +78,7 @@ import { useEventEmitterThree } from "../useEvents";
|
||||
import { useWakeLock } from "../useWakeLock";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
import { useIsRoomE2EE } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
|
||||
@@ -133,6 +134,8 @@ export function InCallView({
|
||||
usePreventScroll();
|
||||
useWakeLock();
|
||||
|
||||
const isRoomE2EE = useIsRoomE2EE(rtcSession.room.roomId);
|
||||
|
||||
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
||||
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
|
||||
const boundsValid = bounds.height > 0;
|
||||
@@ -408,7 +411,7 @@ export function InCallView({
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo roomName={rtcSession.room.name} />
|
||||
<E2EELock />
|
||||
{!isRoomE2EE && <E2EELock />}
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<GridLayoutMenu layout={layout} setLayout={setLayout} />
|
||||
@@ -445,12 +448,7 @@ export function InCallView({
|
||||
/>
|
||||
)}
|
||||
{inviteModalState.isOpen && (
|
||||
<InviteModal
|
||||
roomIdOrAlias={
|
||||
rtcSession.room.getCanonicalAlias() ?? rtcSession.room.roomId
|
||||
}
|
||||
{...inviteModalProps}
|
||||
/>
|
||||
<InviteModal roomId={rtcSession.room.roomId} {...inviteModalProps} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
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 { getRoomUrl } from "../matrix-utils";
|
||||
import styles from "./InviteModal.module.css";
|
||||
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
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 roomSharedKey = useRoomSharedKey(roomId);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -40,7 +42,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, roomSharedKey ?? undefined)}
|
||||
data-testid="modal_inviteLink"
|
||||
/>
|
||||
</ModalContent>
|
||||
|
||||
@@ -14,14 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
ChangeEvent,
|
||||
FC,
|
||||
} from "react";
|
||||
import { useRef, useEffect, FC } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./LobbyView.module.css";
|
||||
@@ -32,15 +25,13 @@ import { UserMenuContainer } from "../UserMenuContainer";
|
||||
import { Body, Link } from "../typography/Typography";
|
||||
import { useLocationNavigation } from "../useLocationNavigation";
|
||||
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 { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
interface Props {
|
||||
matrixInfo: MatrixInfo;
|
||||
muteStates: MuteStates;
|
||||
onEnter: (e2eeConfig?: E2EEConfig) => void;
|
||||
onEnter: () => void;
|
||||
isEmbedded: boolean;
|
||||
hideHeader: boolean;
|
||||
}
|
||||
@@ -53,10 +44,9 @@ export const LobbyView: FC<Props> = ({
|
||||
hideHeader,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const roomSharedKey = useRoomSharedKey(matrixInfo.roomId);
|
||||
useLocationNavigation();
|
||||
|
||||
const [enableE2EE] = useEnableE2EE();
|
||||
|
||||
const joinCallButtonRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (joinCallButtonRef.current) {
|
||||
@@ -64,18 +54,6 @@ export const LobbyView: FC<Props> = ({
|
||||
}
|
||||
}, [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 (
|
||||
<div className={styles.room}>
|
||||
{!hideHeader && (
|
||||
@@ -91,25 +69,12 @@ export const LobbyView: FC<Props> = ({
|
||||
<div className={styles.joinRoom}>
|
||||
<div className={styles.joinRoomContent}>
|
||||
<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>
|
||||
<Button
|
||||
ref={joinCallButtonRef}
|
||||
className={styles.copyButton}
|
||||
size="lg"
|
||||
onPress={() =>
|
||||
onEnter(
|
||||
e2eeSharedKey ? { sharedKey: e2eeSharedKey } : undefined
|
||||
)
|
||||
}
|
||||
onPress={() => onEnter()}
|
||||
data-testid="lobby_joinCall"
|
||||
>
|
||||
Join call now
|
||||
@@ -117,7 +82,7 @@ export const LobbyView: FC<Props> = ({
|
||||
<Body>Or</Body>
|
||||
<CopyButton
|
||||
variant="secondaryCopy"
|
||||
value={getRoomUrl(matrixInfo.roomAlias ?? matrixInfo.roomId)}
|
||||
value={getRoomUrl(matrixInfo.roomId, roomSharedKey ?? undefined)}
|
||||
className={styles.copyButton}
|
||||
copiedMessage={t("Call link copied")}
|
||||
data-testid="lobby_inviteLink"
|
||||
|
||||
@@ -20,10 +20,14 @@ import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { SyncState } from "matrix-js-sdk/src/sync";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { setLocalStorageItem } from "../useLocalStorage";
|
||||
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
|
||||
import { useEnableE2EE } from "../settings/useSetting";
|
||||
import { getRoomSharedKeyLocalStorageKey } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
export type GroupCallLoaded = {
|
||||
kind: "loaded";
|
||||
@@ -58,6 +62,8 @@ export const useLoadGroupCall = (
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
|
||||
|
||||
const [e2eeEnabled] = useEnableE2EE();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOrCreateRoom = async (): Promise<Room> => {
|
||||
try {
|
||||
@@ -95,8 +101,17 @@ export const useLoadGroupCall = (
|
||||
const [, roomId] = await createRoom(
|
||||
client,
|
||||
roomNameFromRoomId(roomIdOrAlias),
|
||||
createPtt
|
||||
createPtt,
|
||||
e2eeEnabled ?? false
|
||||
);
|
||||
|
||||
if (e2eeEnabled) {
|
||||
setLocalStorageItem(
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
randomString(32)
|
||||
);
|
||||
}
|
||||
|
||||
// likewise, wait for the room
|
||||
await client.waitUntilRoomReadyForGroupCalls(roomId);
|
||||
return client.getRoom(roomId)!;
|
||||
@@ -136,7 +151,7 @@ export const useLoadGroupCall = (
|
||||
.then(fetchOrCreateGroupCall)
|
||||
.then((rtcSession) => setState({ kind: "loaded", rtcSession }))
|
||||
.catch((error) => setState({ kind: "failed", error }));
|
||||
}, [client, roomIdOrAlias, viaServers, createPtt, t]);
|
||||
}, [client, roomIdOrAlias, viaServers, createPtt, t, e2eeEnabled]);
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
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.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events";
|
||||
import { useMemo, useState, useEffect, useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { isE2EESupported } from "livekit-client";
|
||||
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import {
|
||||
getLocalStorageItem,
|
||||
setLocalStorageItem,
|
||||
useLocalStorage,
|
||||
} from "../useLocalStorage";
|
||||
|
||||
type Setting<T> = [T, (value: T) => void];
|
||||
type DisableableSetting<T> = [T, ((value: T) => void) | null];
|
||||
|
||||
// Bus to notify other useSetting consumers when a setting is changed
|
||||
export const settingsBus = new EventEmitter();
|
||||
|
||||
const getSettingKey = (name: string): string => {
|
||||
export const getSettingKey = (name: string): string => {
|
||||
return `matrix-setting-${name}`;
|
||||
};
|
||||
// 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 [value, setValue] = useState<T>(() => {
|
||||
const item = localStorage.getItem(key);
|
||||
return item == null ? defaultValue : JSON.parse(item);
|
||||
});
|
||||
const [item, setItem] = useLocalStorage(key);
|
||||
|
||||
useEffect(() => {
|
||||
settingsBus.on(name, setValue);
|
||||
return () => {
|
||||
settingsBus.off(name, setValue);
|
||||
};
|
||||
}, [name, setValue]);
|
||||
const value = useMemo(
|
||||
() => (item == null ? defaultValue : JSON.parse(item)),
|
||||
[item, defaultValue]
|
||||
);
|
||||
const setValue = useCallback(
|
||||
(value: T) => {
|
||||
setItem(JSON.stringify(value));
|
||||
},
|
||||
[setItem]
|
||||
);
|
||||
|
||||
return [
|
||||
value,
|
||||
useCallback(
|
||||
(newValue: T) => {
|
||||
setValue(newValue);
|
||||
localStorage.setItem(key, JSON.stringify(newValue));
|
||||
settingsBus.emit(name, newValue);
|
||||
},
|
||||
[name, key, setValue]
|
||||
),
|
||||
];
|
||||
return [value, setValue];
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
export const setSetting = <T>(name: string, newValue: T) => {
|
||||
localStorage.setItem(getSettingKey(name), JSON.stringify(newValue));
|
||||
settingsBus.emit(name, newValue);
|
||||
};
|
||||
export const setSetting = <T>(name: string, newValue: T) =>
|
||||
setLocalStorageItem(getSettingKey(name), JSON.stringify(newValue));
|
||||
|
||||
const canEnableSpatialAudio = () => {
|
||||
const { userAgent } = navigator;
|
||||
|
||||
59
src/useLocalStorage.ts
Normal file
59
src/useLocalStorage.ts
Normal 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);
|
||||
};
|
||||
@@ -34,7 +34,13 @@ describe("CallList", () => {
|
||||
|
||||
it("should show room", async () => {
|
||||
const rooms = [
|
||||
{ roomName: "Room #1", roomAlias: "#room-name:server.org" },
|
||||
{
|
||||
roomName: "Room #1",
|
||||
roomAlias: "#room-name:server.org",
|
||||
room: {
|
||||
roomId: "!roomId",
|
||||
},
|
||||
},
|
||||
] as GroupCallRoom[];
|
||||
|
||||
const result = renderComponent(rooms);
|
||||
|
||||
Reference in New Issue
Block a user