diff --git a/package.json b/package.json
index 61672da42..42e505cb8 100644
--- a/package.json
+++ b/package.json
@@ -62,7 +62,7 @@
"i18next-http-backend": "^2.0.0",
"livekit-client": "^2.0.2",
"lodash": "^4.17.21",
- "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#d55c6a36df539f6adacc335efe5b9be27c9cee4a",
+ "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#e874468ba3e84819cf4b342d2e66af67ab4cf804",
"matrix-widget-api": "^1.3.1",
"normalize.css": "^8.0.1",
"pako": "^2.0.4",
diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json
index a1eeec679..fb39c7729 100644
--- a/public/locales/en-GB/app.json
+++ b/public/locales/en-GB/app.json
@@ -60,8 +60,17 @@
"disconnected_banner": "Connectivity to the server has been lost.",
"full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.0>",
"full_screen_view_h1": "<0>Oops, something's gone wrong.0>",
- "group_call_loader_failed_heading": "Call not found",
- "group_call_loader_failed_text": "Calls are now end-to-end encrypted and need to be created from the home page. This helps make sure everyone's using the same encryption key.",
+ "group_call_loader": {
+ "banned_body": "You have been banned from the room.",
+ "banned_heading": "Banned",
+ "call_ended_body": "You have been removed from the call.",
+ "call_ended_heading": "Call ended",
+ "failed_heading": "Call not found",
+ "failed_text": "Calls are now end-to-end encrypted and need to be created from the home page. This helps make sure everyone's using the same encryption key.",
+ "knock_reject_body": "The room members declined your request to join.",
+ "knock_reject_heading": "Not allowed to join",
+ "reason": "Reason"
+ },
"hangup_button_label": "End call",
"header_label": "Element Call Home",
"header_participants_label": "Participants",
@@ -77,8 +86,10 @@
"layout_grid_label": "Grid",
"layout_spotlight_label": "Spotlight",
"lobby": {
+ "ask_to_join": "Ask to join call",
"join_button": "Join call",
- "leave_button": "Back to recents"
+ "leave_button": "Back to recents",
+ "waiting_for_invite": "Request sent"
},
"log_in": "Log In",
"logging_in": "Logging in…",
diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx
index 78eec7fe4..4df2f39e3 100644
--- a/src/FullScreenView.tsx
+++ b/src/FullScreenView.tsx
@@ -58,6 +58,7 @@ interface ErrorViewProps {
export const ErrorView: FC = ({ error }) => {
const location = useLocation();
+ const { confineToRoom } = useUrlParams();
const { t } = useTranslation();
useEffect(() => {
@@ -78,25 +79,26 @@ export const ErrorView: FC = ({ error }) => {
: error.message}
- {location.pathname === "/" ? (
-
- {t("return_home_button")}
-
- ) : (
-
- {t("return_home_button")}
-
- )}
+ {!confineToRoom &&
+ (location.pathname === "/" ? (
+
+ {t("return_home_button")}
+
+ ) : (
+
+ {t("return_home_button")}
+
+ ))}
);
};
diff --git a/src/Header.tsx b/src/Header.tsx
index 7ca8929d8..1bf8a4a72 100644
--- a/src/Header.tsx
+++ b/src/Header.tsx
@@ -117,7 +117,7 @@ interface RoomHeaderInfoProps {
name: string;
avatarUrl: string | null;
encrypted: boolean;
- participantCount: number;
+ participantCount: number | null;
}
export const RoomHeaderInfo: FC = ({
@@ -150,7 +150,7 @@ export const RoomHeaderInfo: FC = ({
- {participantCount > 0 && (
+ {(participantCount ?? 0) > 0 && (
= ({
aria-label={t("header_participants_label")}
/>
- {t("participant_count", { count: participantCount })}
+ {t("participant_count", { count: participantCount ?? 0 })}
)}
diff --git a/src/UrlParams.ts b/src/UrlParams.ts
index 78735a8e2..3977b3df5 100644
--- a/src/UrlParams.ts
+++ b/src/UrlParams.ts
@@ -16,10 +16,11 @@ limitations under the License.
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
+import { logger } from "matrix-js-sdk/src/logger";
import { Config } from "./config/Config";
-
-export const PASSWORD_STRING = "password=";
+import { EncryptionSystem } from "./e2ee/sharedKeyManagement";
+import { E2eeType } from "./e2ee/e2eeType";
interface RoomIdentifier {
roomAlias: string | null;
@@ -328,3 +329,32 @@ export const useRoomIdentifier = (): RoomIdentifier => {
[pathname, search, hash],
);
};
+
+export function generateUrlSearchParams(
+ roomId: string,
+ encryptionSystem: EncryptionSystem,
+ viaServers?: string[],
+): URLSearchParams {
+ const params = new URLSearchParams();
+ // The password shouldn't need URL encoding here (we generate URL-safe ones) but encode
+ // it in case it came from another client that generated a non url-safe one
+ switch (encryptionSystem?.kind) {
+ case E2eeType.SHARED_KEY: {
+ const encodedPassword = encodeURIComponent(encryptionSystem.secret);
+ if (encodedPassword !== encryptionSystem.secret) {
+ logger.info(
+ "Encoded call password used non URL-safe chars: buggy client?",
+ );
+ }
+ params.set("password", encodedPassword);
+ break;
+ }
+ case E2eeType.PER_PARTICIPANT:
+ params.set("perParticipantE2EE", "true");
+ break;
+ }
+ params.set("roomId", roomId);
+ viaServers?.forEach((s) => params.set("viaServers", s));
+
+ return params;
+}
diff --git a/src/e2ee/sharedKeyManagement.ts b/src/e2ee/sharedKeyManagement.ts
index 2f46c5328..6f826cfea 100644
--- a/src/e2ee/sharedKeyManagement.ts
+++ b/src/e2ee/sharedKeyManagement.ts
@@ -15,12 +15,11 @@ limitations under the License.
*/
import { useEffect, useMemo } from "react";
-import { Room } from "matrix-js-sdk";
import { setLocalStorageItem, useLocalStorage } from "../useLocalStorage";
-import { useClient } from "../ClientContext";
import { UrlParams, getUrlParams, useUrlParams } from "../UrlParams";
-import { widget } from "../widget";
+import { E2eeType } from "./e2eeType";
+import { useClient } from "../ClientContext";
export function saveKeyForRoom(roomId: string, password: string): void {
setLocalStorageItem(getRoomSharedKeyLocalStorageKey(roomId), password);
@@ -68,30 +67,37 @@ const useKeyFromUrl = (): [string, string] | [undefined, undefined] => {
: [undefined, undefined];
};
-export const useRoomSharedKey = (roomId: string): string | undefined => {
+export type Unencrypted = { kind: E2eeType.NONE };
+export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
+export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
+export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
+
+export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
+ const { client } = useClient();
+
// make sure we've extracted the key from the URL first
// (and we still need to take the value it returns because
// the effect won't run in time for it to save to localstorage in
// time for us to read it out again).
- const [urlRoomId, passwordFormUrl] = useKeyFromUrl();
-
+ const [urlRoomId, passwordFromUrl] = useKeyFromUrl();
const storedPassword = useInternalRoomSharedKey(roomId);
-
- if (storedPassword) return storedPassword;
- if (urlRoomId === roomId) return passwordFormUrl;
- return undefined;
-};
-
-export const useIsRoomE2EE = (roomId: string): boolean | null => {
- const { client } = useClient();
- const room = useMemo(() => client?.getRoom(roomId), [roomId, client]);
-
- return useMemo(() => !room || isRoomE2EE(room), [room]);
-};
-
-export function isRoomE2EE(room: Room): boolean {
- // For now, rooms in widget mode are never considered encrypted.
- // In the future, when widget mode gains encryption support, then perhaps we
- // should inspect the e2eEnabled URL parameter here?
- return widget === null && !room.getCanonicalAlias();
+ const room = client?.getRoom(roomId);
+ const e2eeSystem = useMemo(() => {
+ if (!room) return { kind: E2eeType.NONE };
+ if (storedPassword)
+ return {
+ kind: E2eeType.SHARED_KEY,
+ secret: storedPassword,
+ };
+ if (urlRoomId === roomId)
+ return {
+ kind: E2eeType.SHARED_KEY,
+ secret: passwordFromUrl,
+ };
+ if (room.hasEncryptionStateEvent()) {
+ return { kind: E2eeType.PER_PARTICIPANT };
+ }
+ return { kind: E2eeType.NONE };
+ }, [passwordFromUrl, room, roomId, storedPassword, urlRoomId]);
+ return e2eeSystem;
}
diff --git a/src/home/CallList.tsx b/src/home/CallList.tsx
index 187228b9d..bb22d4e8f 100644
--- a/src/home/CallList.tsx
+++ b/src/home/CallList.tsx
@@ -26,7 +26,7 @@ import styles from "./CallList.module.css";
import { getAbsoluteRoomUrl, getRelativeRoomUrl } from "../matrix-utils";
import { Body } from "../typography/Typography";
import { GroupCallRoom } from "./useGroupCallRooms";
-import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
+import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
interface CallListProps {
rooms: GroupCallRoom[];
@@ -66,16 +66,11 @@ interface CallTileProps {
}
const CallTile: FC = ({ name, avatarUrl, room }) => {
- const roomSharedKey = useRoomSharedKey(room.roomId);
-
+ const roomEncryptionSystem = useRoomEncryptionSystem(room.roomId);
return (
@@ -89,11 +84,8 @@ const CallTile: FC
= ({ name, avatarUrl, room }) => {
);
diff --git a/src/home/RegisteredView.tsx b/src/home/RegisteredView.tsx
index 8024467fb..35e958ab0 100644
--- a/src/home/RegisteredView.tsx
+++ b/src/home/RegisteredView.tsx
@@ -78,12 +78,14 @@ export const RegisteredView: FC = ({ client }) => {
roomName,
E2eeType.SHARED_KEY,
);
+ if (!createRoomResult.password)
+ throw new Error("Failed to create room with shared secret");
history.push(
getRelativeRoomUrl(
createRoomResult.roomId,
+ { kind: E2eeType.SHARED_KEY, secret: createRoomResult.password },
roomName,
- createRoomResult.password,
),
);
}
diff --git a/src/home/UnauthenticatedView.tsx b/src/home/UnauthenticatedView.tsx
index c580bcd8e..d5f00fea5 100644
--- a/src/home/UnauthenticatedView.tsx
+++ b/src/home/UnauthenticatedView.tsx
@@ -116,13 +116,15 @@ export const UnauthenticatedView: FC = () => {
if (!setClient) {
throw new Error("setClient is undefined");
}
+ if (!createRoomResult.password)
+ throw new Error("Failed to create room with shared secret");
setClient({ client, session });
history.push(
getRelativeRoomUrl(
createRoomResult.roomId,
+ { kind: E2eeType.SHARED_KEY, secret: createRoomResult.password },
roomName,
- createRoomResult.password,
),
);
}
diff --git a/src/home/useGroupCallRooms.ts b/src/home/useGroupCallRooms.ts
index bea7ea1ee..d2880795d 100644
--- a/src/home/useGroupCallRooms.ts
+++ b/src/home/useGroupCallRooms.ts
@@ -15,20 +15,22 @@ limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk/src/client";
-import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
-import { Room } from "matrix-js-sdk/src/models/room";
+import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
-import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
import { useState, useEffect } from "react";
+import { EventTimeline, EventType, JoinRule } from "matrix-js-sdk";
+import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
+import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager";
+import { KnownMembership } from "matrix-js-sdk/src/types";
-import { getKeyForRoom, isRoomE2EE } from "../e2ee/sharedKeyManagement";
+import { getKeyForRoom } from "../e2ee/sharedKeyManagement";
export interface GroupCallRoom {
roomAlias?: string;
roomName: string;
avatarUrl: string;
room: Room;
- groupCall: GroupCall;
+ session: MatrixRTCSession;
participants: RoomMember[];
}
const tsCache: { [index: string]: number } = {};
@@ -46,7 +48,7 @@ function getLastTs(client: MatrixClient, r: Room): number {
const myUserId = client.getUserId()!;
- if (r.getMyMembership() !== "join") {
+ if (r.getMyMembership() !== KnownMembership.Join) {
const membershipEvent = r.currentState.getStateEvents(
"m.room.member",
myUserId,
@@ -80,38 +82,51 @@ function sortRooms(client: MatrixClient, rooms: Room[]): Room[] {
});
}
-function roomIsJoinable(room: Room): boolean {
- if (isRoomE2EE(room)) {
- return Boolean(getKeyForRoom(room.roomId));
- } else {
- return true;
+const roomIsJoinable = (room: Room): boolean => {
+ if (!room.hasEncryptionStateEvent() && !getKeyForRoom(room.roomId)) {
+ // if we have an non encrypted room (no encryption state event) we need a locally stored shared key.
+ // in case this key also does not exists we cannot join the room.
+ return false;
}
-}
+ // otherwise we can always join rooms because we will automatically decide if we want to use perParticipant or password
+ const joinRule = room.getJoinRule();
+ return joinRule === JoinRule.Knock || joinRule === JoinRule.Public;
+};
+
+const roomHasCallMembershipEvents = (room: Room): boolean => {
+ const roomStateEvents = room
+ .getLiveTimeline()
+ .getState(EventTimeline.FORWARDS)?.events;
+ return (
+ room.getMyMembership() === KnownMembership.Join &&
+ !!roomStateEvents?.get(EventType.GroupCallMemberPrefix)
+ );
+};
export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
const [rooms, setRooms] = useState([]);
useEffect(() => {
function updateRooms(): void {
- if (!client.groupCallEventHandler) {
- return;
- }
-
- const groupCalls = client.groupCallEventHandler.groupCalls.values();
- const rooms = Array.from(groupCalls)
- .map((groupCall) => groupCall.room)
+ // We want to show all rooms that historically had a call and which we are (can become) part of.
+ const rooms = client
+ .getRooms()
+ .filter(roomHasCallMembershipEvents)
.filter(roomIsJoinable);
const sortedRooms = sortRooms(client, rooms);
const items = sortedRooms.map((room) => {
- const groupCall = client.getGroupCallForRoom(room.roomId)!;
-
+ const session = client.matrixRTC.getRoomSession(room);
+ session.memberships;
return {
roomAlias: room.getCanonicalAlias() ?? undefined,
roomName: room.name,
avatarUrl: room.getMxcAvatarUrl()!,
room,
- groupCall,
- participants: [...groupCall!.participants.keys()],
+ session,
+ participants: session.memberships
+ .filter((m) => m.sender)
+ .map((m) => room.getMember(m.sender!))
+ .filter((m) => m) as RoomMember[],
};
});
@@ -120,15 +135,17 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
updateRooms();
- client.on(GroupCallEventHandlerEvent.Incoming, updateRooms);
- client.on(GroupCallEventHandlerEvent.Participants, updateRooms);
-
+ client.matrixRTC.on(
+ MatrixRTCSessionManagerEvents.SessionStarted,
+ updateRooms,
+ );
+ client.on(RoomEvent.MyMembership, updateRooms);
return () => {
- client.removeListener(GroupCallEventHandlerEvent.Incoming, updateRooms);
- client.removeListener(
- GroupCallEventHandlerEvent.Participants,
+ client.matrixRTC.off(
+ MatrixRTCSessionManagerEvents.SessionStarted,
updateRooms,
);
+ client.off(RoomEvent.MyMembership, updateRooms);
};
}, [client]);
diff --git a/src/livekit/useLiveKit.ts b/src/livekit/useLiveKit.ts
index f68586936..988dc0f88 100644
--- a/src/livekit/useLiveKit.ts
+++ b/src/livekit/useLiveKit.ts
@@ -41,11 +41,7 @@ import {
} from "./useECConnectionState";
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
import { E2eeType } from "../e2ee/e2eeType";
-
-export type E2EEConfig = {
- mode: E2eeType;
- sharedKey?: string;
-};
+import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
interface UseLivekitResult {
livekitRoom?: Room;
@@ -56,41 +52,35 @@ export function useLiveKit(
rtcSession: MatrixRTCSession,
muteStates: MuteStates,
sfuConfig: SFUConfig | undefined,
- e2eeConfig: E2EEConfig,
+ e2eeSystem: EncryptionSystem,
): UseLivekitResult {
const e2eeOptions = useMemo((): E2EEOptions | undefined => {
- if (e2eeConfig.mode === E2eeType.NONE) return undefined;
+ if (e2eeSystem.kind === E2eeType.NONE) return undefined;
- if (e2eeConfig.mode === E2eeType.PER_PARTICIPANT) {
+ if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
return {
keyProvider: new MatrixKeyProvider(),
worker: new E2EEWorker(),
};
- } else if (
- e2eeConfig.mode === E2eeType.SHARED_KEY &&
- e2eeConfig.sharedKey
- ) {
+ } else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
return {
keyProvider: new ExternalE2EEKeyProvider(),
worker: new E2EEWorker(),
};
}
- }, [e2eeConfig]);
+ }, [e2eeSystem]);
useEffect(() => {
- if (e2eeConfig.mode === E2eeType.NONE || !e2eeOptions) return;
+ if (e2eeSystem.kind === E2eeType.NONE || !e2eeOptions) return;
- if (e2eeConfig.mode === E2eeType.PER_PARTICIPANT) {
+ if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
(e2eeOptions.keyProvider as MatrixKeyProvider).setRTCSession(rtcSession);
- } else if (
- e2eeConfig.mode === E2eeType.SHARED_KEY &&
- e2eeConfig.sharedKey
- ) {
+ } else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
(e2eeOptions.keyProvider as ExternalE2EEKeyProvider).setKey(
- e2eeConfig.sharedKey,
+ e2eeSystem.secret,
);
}
- }, [e2eeOptions, e2eeConfig, rtcSession]);
+ }, [e2eeOptions, e2eeSystem, rtcSession]);
const initialMuteStates = useRef(muteStates);
const devices = useMediaDevices();
@@ -131,9 +121,9 @@ export function useLiveKit(
// useEffect() with an argument that references itself, if E2EE is enabled
const room = useMemo(() => {
const r = new Room(roomOptions);
- r.setE2EEEnabled(e2eeConfig.mode !== E2eeType.NONE);
+ r.setE2EEEnabled(e2eeSystem.kind !== E2eeType.NONE);
return r;
- }, [roomOptions, e2eeConfig]);
+ }, [roomOptions, e2eeSystem]);
const connectionState = useECConnectionState(
{
diff --git a/src/matrix-utils.ts b/src/matrix-utils.ts
index f5025710c..c93580b12 100644
--- a/src/matrix-utils.ts
+++ b/src/matrix-utils.ts
@@ -24,20 +24,16 @@ 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";
import { logger } from "matrix-js-sdk/src/logger";
-import {
- GroupCallIntent,
- GroupCallType,
-} from "matrix-js-sdk/src/webrtc/groupCall";
import { secureRandomBase64Url } from "matrix-js-sdk/src/randomstring";
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, PASSWORD_STRING } from "./UrlParams";
+import { generateUrlSearchParams, getUrlParams } from "./UrlParams";
import { loadOlm } from "./olm";
import { Config } from "./config/Config";
import { E2eeType } from "./e2ee/e2eeType";
-import { saveKeyForRoom } from "./e2ee/sharedKeyManagement";
+import { EncryptionSystem, saveKeyForRoom } from "./e2ee/sharedKeyManagement";
export const fallbackICEServerAllowed =
import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true";
@@ -338,16 +334,6 @@ export async function createRoom(
const result = await createPromise;
- logger.log(`Creating group call in ${result.room_id}`);
-
- await client.createGroupCall(
- result.room_id,
- GroupCallType.Video,
- false,
- GroupCallIntent.Room,
- true,
- );
-
let password;
if (e2ee == E2eeType.SHARED_KEY) {
password = secureRandomBase64Url(16);
@@ -365,39 +351,35 @@ export async function createRoom(
* Returns an absolute URL to that will load Element Call with the given room
* @param roomId ID of the room
* @param roomName Name of the room
- * @param password e2e key for the room
+ * @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
*/
export function getAbsoluteRoomUrl(
roomId: string,
+ encryptionSystem: EncryptionSystem,
roomName?: string,
- password?: string,
+ viaServers?: string[],
): string {
return `${window.location.protocol}//${
window.location.host
- }${getRelativeRoomUrl(roomId, roomName, password)}`;
+ }${getRelativeRoomUrl(roomId, encryptionSystem, roomName, viaServers)}`;
}
/**
* Returns a relative URL to that will load Element Call with the given room
* @param roomId ID of the room
* @param roomName Name of the room
- * @param password e2e key for the room
+ * @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
*/
export function getRelativeRoomUrl(
roomId: string,
+ encryptionSystem: EncryptionSystem,
roomName?: string,
- password?: string,
+ viaServers?: string[],
): string {
- // The password shouldn't need URL encoding here (we generate URL-safe ones) but encode
- // it in case it came from another client that generated a non url-safe one
- const encodedPassword = password ? encodeURIComponent(password) : undefined;
- if (password && encodedPassword !== password) {
- logger.info("Encoded call password used non URL-safe chars: buggy client?");
- }
-
- return `/room/#${
- roomName ? "/" + roomAliasLocalpartFromRoomName(roomName) : ""
- }?roomId=${roomId}${password ? "&" + PASSWORD_STRING + encodedPassword : ""}`;
+ const roomPart = roomName
+ ? "/" + roomAliasLocalpartFromRoomName(roomName)
+ : "";
+ return `/room/#${roomPart}?${generateUrlSearchParams(roomId, encryptionSystem, viaServers).toString()}`;
}
export function getAvatarUrl(
diff --git a/src/room/AppSelectionModal.tsx b/src/room/AppSelectionModal.tsx
index 1a9009f57..ffb9932d0 100644
--- a/src/room/AppSelectionModal.tsx
+++ b/src/room/AppSelectionModal.tsx
@@ -21,13 +21,14 @@ import PopOutIcon from "@vector-im/compound-design-tokens/icons/pop-out.svg?reac
import { logger } from "matrix-js-sdk/src/logger";
import { Modal } from "../Modal";
-import { useIsRoomE2EE, useRoomSharedKey } from "../e2ee/sharedKeyManagement";
+import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { getAbsoluteRoomUrl } from "../matrix-utils";
import styles from "./AppSelectionModal.module.css";
import { editFragmentQuery } from "../UrlParams";
+import { E2eeType } from "../e2ee/e2eeType";
interface Props {
- roomId: string | null;
+ roomId: string;
}
export const AppSelectionModal: FC = ({ roomId }) => {
@@ -42,10 +43,9 @@ export const AppSelectionModal: FC = ({ roomId }) => {
},
[setOpen],
);
+ const e2eeSystem = useRoomEncryptionSystem(roomId);
- const roomSharedKey = useRoomSharedKey(roomId ?? "");
- const roomIsEncrypted = useIsRoomE2EE(roomId ?? "");
- if (roomIsEncrypted && roomSharedKey === undefined) {
+ if (e2eeSystem.kind === E2eeType.NONE) {
logger.error(
"Generating app redirect URL for encrypted room but don't have key available!",
);
@@ -60,7 +60,7 @@ export const AppSelectionModal: FC = ({ roomId }) => {
const url = new URL(
roomId === null
? window.location.href
- : getAbsoluteRoomUrl(roomId, undefined, roomSharedKey ?? undefined),
+ : getAbsoluteRoomUrl(roomId, e2eeSystem),
);
// Edit the URL to prevent the app selection prompt from appearing a second
// time within the app, and to keep the user confined to the current room
@@ -73,7 +73,7 @@ export const AppSelectionModal: FC = ({ roomId }) => {
const result = new URL("io.element.call:/");
result.searchParams.set("url", url.toString());
return result.toString();
- }, [roomId, roomSharedKey]);
+ }, [e2eeSystem, roomId]);
return (
ReactNode;
+ children: (groupCallState: GroupCallStatus) => JSX.Element;
}
export function GroupCallLoader({
@@ -51,20 +54,22 @@ export function GroupCallLoader({
);
switch (groupCallState.kind) {
+ case "loaded":
+ case "waitForInvite":
+ case "canKnock":
+ return children(groupCallState);
case "loading":
return (
{t("common.loading")}
);
- case "loaded":
- return <>{children(groupCallState.rtcSession)}>;
case "failed":
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
return (
- {t("group_call_loader_failed_heading")}
- {t("group_call_loader_failed_text")}
+ {t("group_call_loader.failed_heading")}
+ {t("group_call_loader.failed_text")}
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
dupes of this flow, let's make a common component and put it here. */}
@@ -72,6 +77,22 @@ export function GroupCallLoader({
);
+ } else if (groupCallState.error instanceof CallTerminatedMessage) {
+ return (
+
+ {groupCallState.error.message}
+ {groupCallState.error.messageBody}
+ {groupCallState.error.reason && (
+ <>
+ {t("group_call_loader.reason")}:
+ "{groupCallState.error.reason}"
+ >
+ )}
+
+ {t("common.home")}
+
+
+ );
} else {
return ;
}
diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx
index 9b65ac7b9..a3fe1dbd4 100644
--- a/src/room/GroupCallView.tsx
+++ b/src/room/GroupCallView.tsx
@@ -17,7 +17,10 @@ limitations under the License.
import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useHistory } from "react-router-dom";
import { MatrixClient } from "matrix-js-sdk/src/client";
-import { Room, isE2EESupported } from "livekit-client";
+import {
+ Room,
+ isE2EESupported as isE2EESupportedBrowser,
+} from "livekit-client";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { JoinRule } from "matrix-js-sdk/src/matrix";
@@ -26,7 +29,7 @@ import { useTranslation } from "react-i18next";
import type { IWidgetApiRequest } from "matrix-widget-api";
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
-import { ErrorView, FullScreenView } from "../FullScreenView";
+import { FullScreenView } from "../FullScreenView";
import { LobbyView } from "./LobbyView";
import { MatrixInfo } from "./VideoPreview";
import { CallEndedView } from "./CallEndedView";
@@ -34,17 +37,16 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useProfile } from "../profile/useProfile";
import { findDeviceByName } from "../media-utils";
import { ActiveCall } from "./InCallView";
-import { MuteStates, useMuteStates } from "./MuteStates";
+import { MUTE_PARTICIPANT_COUNT, MuteStates } from "./MuteStates";
import { useMediaDevices, MediaDevices } from "../livekit/MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
-import { useIsRoomE2EE, useRoomSharedKey } from "../e2ee/sharedKeyManagement";
+import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal";
-import { E2EEConfig } from "../livekit/useLiveKit";
import { useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
@@ -62,6 +64,7 @@ interface Props {
skipLobby: boolean;
hideHeader: boolean;
rtcSession: MatrixRTCSession;
+ muteStates: MuteStates;
}
export const GroupCallView: FC = ({
@@ -72,10 +75,23 @@ export const GroupCallView: FC = ({
skipLobby,
hideHeader,
rtcSession,
+ muteStates,
}) => {
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
+ // The mute state reactively gets updated once the participant count reaches the threshold.
+ // The user then still is able to unmute again.
+ // The more common case is that the user is muted from the start (participant count is already over the threshold).
+ const autoMuteHappened = useRef(false);
+ useEffect(() => {
+ if (autoMuteHappened.current) return;
+ if (memberships.length >= MUTE_PARTICIPANT_COUNT) {
+ muteStates.audio.setEnabled?.(false);
+ autoMuteHappened.current = true;
+ }
+ }, [autoMuteHappened, memberships, muteStates.audio]);
+
useEffect(() => {
window.rtcSession = rtcSession;
return () => {
@@ -86,10 +102,8 @@ export const GroupCallView: FC = ({
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(rtcSession.room);
const roomAvatar = useRoomAvatar(rtcSession.room);
- const e2eeSharedKey = useRoomSharedKey(rtcSession.room.roomId);
const { perParticipantE2EE, returnToLobby } = useUrlParams();
- const roomEncrypted =
- useIsRoomE2EE(rtcSession.room.roomId) || perParticipantE2EE;
+ const e2eeSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
const matrixInfo = useMemo((): MatrixInfo => {
return {
@@ -100,16 +114,16 @@ export const GroupCallView: FC = ({
roomName,
roomAlias: rtcSession.room.getCanonicalAlias(),
roomAvatar,
- roomEncrypted,
+ e2eeSystem,
};
}, [
+ client,
displayName,
avatarUrl,
- rtcSession,
+ rtcSession.room,
roomName,
roomAvatar,
- roomEncrypted,
- client,
+ e2eeSystem,
]);
// Count each member only once, regardless of how many devices they use
@@ -122,20 +136,9 @@ export const GroupCallView: FC = ({
const latestDevices = useRef();
latestDevices.current = deviceContext;
- const muteStates = useMuteStates(memberships.length);
const latestMuteStates = useRef();
latestMuteStates.current = muteStates;
- const e2eeConfig = useMemo((): E2EEConfig => {
- if (perParticipantE2EE) {
- return { mode: E2eeType.PER_PARTICIPANT };
- } else if (e2eeSharedKey) {
- return { mode: E2eeType.SHARED_KEY, sharedKey: e2eeSharedKey };
- } else {
- return { mode: E2eeType.NONE };
- }
- }, [perParticipantE2EE, e2eeSharedKey]);
-
useEffect(() => {
const defaultDeviceSetup = async (
requestedDeviceData: JoinCallData,
@@ -288,17 +291,8 @@ export const GroupCallView: FC = ({
const { t } = useTranslation();
- if (roomEncrypted && !perParticipantE2EE && !e2eeSharedKey) {
- return (
-
- );
- } else if (!isE2EESupported() && roomEncrypted) {
+ if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) {
+ // If we have a encryption system but the browser does not support it.
return (
{t("browser_media_e2ee_unsupported_heading")}
@@ -345,7 +339,7 @@ export const GroupCallView: FC = ({
onLeave={onLeave}
hideHeader={hideHeader}
muteStates={muteStates}
- e2eeConfig={e2eeConfig}
+ e2eeSystem={e2eeSystem}
//otelGroupCallMembership={otelGroupCallMembership}
onShareClick={onShareClick}
/>
diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx
index 5f6ff4a2d..56cdba49a 100644
--- a/src/room/InCallView.tsx
+++ b/src/room/InCallView.tsx
@@ -63,7 +63,7 @@ import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
-import { E2EEConfig, useLiveKit } from "../livekit/useLiveKit";
+import { useLiveKit } from "../livekit/useLiveKit";
import { useFullscreen } from "./useFullscreen";
import { useLayoutStates } from "../video-grid/Layout";
import { useWakeLock } from "../useWakeLock";
@@ -76,13 +76,15 @@ import { ECConnectionState } from "../livekit/useECConnectionState";
import { useOpenIDSFU } from "../livekit/openIDSFU";
import { useCallViewModel } from "../state/CallViewModel";
import { subscribe } from "../state/subscribe";
+import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
+import { E2eeType } from "../e2ee/e2eeType";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
export interface ActiveCallProps
extends Omit {
- e2eeConfig: E2EEConfig;
+ e2eeSystem: EncryptionSystem;
}
export const ActiveCall: FC = (props) => {
@@ -91,7 +93,7 @@ export const ActiveCall: FC = (props) => {
props.rtcSession,
props.muteStates,
sfuConfig,
- props.e2eeConfig,
+ props.e2eeSystem,
);
useEffect(() => {
@@ -238,7 +240,7 @@ export const InCallView: FC = subscribe(
const vm = useCallViewModel(
rtcSession.room,
livekitRoom,
- matrixInfo.roomEncrypted,
+ matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
connState,
);
const items = useStateObservable(vm.tiles);
@@ -432,7 +434,7 @@ export const InCallView: FC = subscribe(
id={matrixInfo.roomId}
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
- encrypted={matrixInfo.roomEncrypted}
+ encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount}
/>
diff --git a/src/room/InviteModal.tsx b/src/room/InviteModal.tsx
index caecab44c..3a66ebea1 100644
--- a/src/room/InviteModal.tsx
+++ b/src/room/InviteModal.tsx
@@ -25,8 +25,8 @@ import useClipboard from "react-use-clipboard";
import { Modal } from "../Modal";
import { getAbsoluteRoomUrl } from "../matrix-utils";
import styles from "./InviteModal.module.css";
-import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
import { Toast } from "../Toast";
+import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
interface Props {
room: Room;
@@ -36,11 +36,11 @@ interface Props {
export const InviteModal: FC = ({ room, open, onDismiss }) => {
const { t } = useTranslation();
- const roomSharedKey = useRoomSharedKey(room.roomId);
+ const e2eeSystem = useRoomEncryptionSystem(room.roomId);
+
const url = useMemo(
- () =>
- getAbsoluteRoomUrl(room.roomId, room.name, roomSharedKey ?? undefined),
- [room, roomSharedKey],
+ () => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
+ [e2eeSystem, room.name, room.roomId],
);
const [, setCopied] = useClipboard(url);
const [toastOpen, setToastOpen] = useState(false);
diff --git a/src/room/LobbyView.module.css b/src/room/LobbyView.module.css
index 168ce69ee..8a3d2a2c0 100644
--- a/src/room/LobbyView.module.css
+++ b/src/room/LobbyView.module.css
@@ -25,6 +25,18 @@ limitations under the License.
height: 100%;
}
+.wait {
+ color: var(--cpd-color-text-primary) !important;
+ background-color: var(--cpd-color-bg-canvas-default) !important;
+ /* relative colors are only supported on chromium based browsers */
+ background-color: rgb(
+ from var(--cpd-color-bg-canvas-default) r g b / 0.5
+ ) !important;
+}
+.wait > svg {
+ color: var(--cpd-color-theme-primary) !important;
+}
+
@media (max-width: 500px) {
.join {
width: 100%;
diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx
index 08cc0f05b..14ff4bc19 100644
--- a/src/room/LobbyView.tsx
+++ b/src/room/LobbyView.tsx
@@ -21,8 +21,8 @@ import { Button, Link } from "@vector-im/compound-web";
import classNames from "classnames";
import { useHistory } from "react-router-dom";
-import styles from "./LobbyView.module.css";
import inCallStyles from "./InCallView.module.css";
+import styles from "./LobbyView.module.css";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { useLocationNavigation } from "../useLocationNavigation";
import { MatrixInfo, VideoPreview } from "./VideoPreview";
@@ -36,16 +36,19 @@ import {
} from "../button/Button";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useMediaQuery } from "../useMediaQuery";
+import { E2eeType } from "../e2ee/e2eeType";
interface Props {
client: MatrixClient;
matrixInfo: MatrixInfo;
muteStates: MuteStates;
onEnter: () => void;
+ enterLabel?: JSX.Element | string;
confineToRoom: boolean;
hideHeader: boolean;
- participantCount: number;
+ participantCount: number | null;
onShareClick: (() => void) | null;
+ waitingForInvite?: boolean;
}
export const LobbyView: FC = ({
@@ -53,10 +56,12 @@ export const LobbyView: FC = ({
matrixInfo,
muteStates,
onEnter,
+ enterLabel,
confineToRoom,
hideHeader,
participantCount,
onShareClick,
+ waitingForInvite,
}) => {
const { t } = useTranslation();
useLocationNavigation();
@@ -104,7 +109,7 @@ export const LobbyView: FC = ({
id={matrixInfo.roomId}
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
- encrypted={matrixInfo.roomEncrypted}
+ encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount}
/>
@@ -116,12 +121,16 @@ export const LobbyView: FC = ({
{
+ if (!waitingForInvite) onEnter();
+ }}
data-testid="lobby_joinCall"
>
- {t("lobby.join_button")}
+ {enterLabel ?? t("lobby.join_button")}
{!recentsButtonInFooter && recentsButton}
diff --git a/src/room/MuteStates.ts b/src/room/MuteStates.ts
index db1fb22a3..7a5d99dba 100644
--- a/src/room/MuteStates.ts
+++ b/src/room/MuteStates.ts
@@ -20,10 +20,10 @@ import { MediaDevice, useMediaDevices } from "../livekit/MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
/**
- * If there already is this many participants in the call, we automatically mute
- * the user
+ * If there already are this many participants in the call, we automatically mute
+ * the user.
*/
-const MUTE_PARTICIPANT_COUNT = 8;
+export const MUTE_PARTICIPANT_COUNT = 8;
interface DeviceAvailable {
enabled: boolean;
@@ -51,26 +51,27 @@ function useMuteState(
device: MediaDevice,
enabledByDefault: () => boolean,
): MuteState {
- const [enabled, setEnabled] = useReactiveState
(
- (prev) => device.available.length > 0 && (prev ?? enabledByDefault()),
+ const [enabled, setEnabled] = useReactiveState(
+ (prev) =>
+ device.available.length > 0 ? prev ?? enabledByDefault() : undefined,
[device],
);
return useMemo(
() =>
device.available.length === 0
? deviceUnavailable
- : { enabled, setEnabled },
+ : {
+ enabled: enabled ?? false,
+ setEnabled: setEnabled as Dispatch>,
+ },
[device, enabled, setEnabled],
);
}
-export function useMuteStates(participantCount: number): MuteStates {
+export function useMuteStates(): MuteStates {
const devices = useMediaDevices();
- const audio = useMuteState(
- devices.audioInput,
- () => participantCount <= MUTE_PARTICIPANT_COUNT,
- );
+ const audio = useMuteState(devices.audioInput, () => true);
const video = useMuteState(devices.videoInput, () => true);
return useMemo(() => ({ audio, video }), [audio, video]);
diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx
index 0e93b5475..2f18cb638 100644
--- a/src/room/RoomPage.tsx
+++ b/src/room/RoomPage.tsx
@@ -15,8 +15,9 @@ limitations under the License.
*/
import { FC, useEffect, useState, useCallback, ReactNode } from "react";
-import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { logger } from "matrix-js-sdk/src/logger";
+import { useTranslation } from "react-i18next";
+import CheckIcon from "@vector-im/compound-design-tokens/icons/check.svg?react";
import { useClientLegacy } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
@@ -30,6 +31,11 @@ import { HomePage } from "../home/HomePage";
import { platform } from "../Platform";
import { AppSelectionModal } from "./AppSelectionModal";
import { widget } from "../widget";
+import { GroupCallStatus } from "./useLoadGroupCall";
+import { LobbyView } from "./LobbyView";
+import { E2eeType } from "../e2ee/e2eeType";
+import { useProfile } from "../profile/useProfile";
+import { useMuteStates } from "./MuteStates";
export const RoomPage: FC = () => {
const {
@@ -40,7 +46,7 @@ export const RoomPage: FC = () => {
displayName,
skipLobby,
} = useUrlParams();
-
+ const { t } = useTranslation();
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
const roomIdOrAlias = roomId ?? roomAlias;
@@ -48,17 +54,14 @@ export const RoomPage: FC = () => {
logger.error("No room specified");
}
- const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const { registerPasswordlessUser } = useRegisterPasswordlessUser();
const [isRegistering, setIsRegistering] = useState(false);
- useEffect(() => {
- // During the beta, opt into analytics by default
- if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
- }, [optInAnalytics, setOptInAnalytics]);
-
const { loading, authenticated, client, error, passwordlessUser } =
useClientLegacy();
+ const { avatarUrl, displayName: userDisplayName } = useProfile(client);
+
+ const muteStates = useMuteStates();
useEffect(() => {
// If we've finished loading, are not already authed and we've been given a display name as
@@ -77,19 +80,87 @@ export const RoomPage: FC = () => {
registerPasswordlessUser,
]);
+ const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
+ useEffect(() => {
+ // During the beta, opt into analytics by default
+ if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
+ }, [optInAnalytics, setOptInAnalytics]);
+
const groupCallView = useCallback(
- (rtcSession: MatrixRTCSession) => (
-
- ),
- [client, passwordlessUser, confineToRoom, preload, hideHeader, skipLobby],
+ (groupCallState: GroupCallStatus): JSX.Element => {
+ switch (groupCallState.kind) {
+ case "loaded":
+ return (
+
+ );
+ case "waitForInvite":
+ case "canKnock": {
+ const knock =
+ groupCallState.kind === "canKnock" ? groupCallState.knock : null;
+ const label: string | JSX.Element =
+ groupCallState.kind === "canKnock" ? (
+ t("lobby.ask_to_join")
+ ) : (
+ <>
+ {t("lobby.waiting_for_invite")}
+
+ >
+ );
+ return (
+ knock?.()}
+ enterLabel={label}
+ waitingForInvite={groupCallState.kind === "waitForInvite"}
+ confineToRoom={confineToRoom}
+ hideHeader={hideHeader}
+ participantCount={null}
+ muteStates={muteStates}
+ onShareClick={null}
+ />
+ );
+ }
+ default:
+ return <> >;
+ }
+ },
+ [
+ client,
+ passwordlessUser,
+ confineToRoom,
+ preload,
+ skipLobby,
+ hideHeader,
+ muteStates,
+ t,
+ userDisplayName,
+ avatarUrl,
+ ],
);
let content: ReactNode;
@@ -118,9 +189,9 @@ export const RoomPage: FC = () => {
<>
{content}
{/* On Android and iOS, show a prompt to launch the mobile app. */}
- {appPrompt && (platform === "android" || platform === "ios") && (
-
- )}
+ {appPrompt &&
+ (platform === "android" || platform === "ios") &&
+ roomId && }
>
);
};
diff --git a/src/room/VideoPreview.tsx b/src/room/VideoPreview.tsx
index 4d1f040c6..602ca5e5b 100644
--- a/src/room/VideoPreview.tsx
+++ b/src/room/VideoPreview.tsx
@@ -32,6 +32,7 @@ import styles from "./VideoPreview.module.css";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { MuteStates } from "./MuteStates";
import { useMediaQuery } from "../useMediaQuery";
+import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
export type MatrixInfo = {
userId: string;
@@ -41,7 +42,7 @@ export type MatrixInfo = {
roomName: string;
roomAlias: string | null;
roomAvatar: string | null;
- roomEncrypted: boolean;
+ e2eeSystem: EncryptionSystem;
};
interface Props {
diff --git a/src/room/useLoadGroupCall.ts b/src/room/useLoadGroupCall.ts
index 920cb8bcb..3a3c279fb 100644
--- a/src/room/useLoadGroupCall.ts
+++ b/src/room/useLoadGroupCall.ts
@@ -14,15 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef, useCallback } from "react";
import { logger } from "matrix-js-sdk/src/logger";
-import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
+import { EventType } from "matrix-js-sdk/src/@types/event";
+import {
+ ClientEvent,
+ MatrixClient,
+ RoomSummary,
+} 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 { RoomEvent, Room } from "matrix-js-sdk/src/models/room";
+import { KnownMembership } from "matrix-js-sdk/src/types";
+import { JoinRule } from "matrix-js-sdk";
+import { useTranslation } from "react-i18next";
-import type { Room } from "matrix-js-sdk/src/models/room";
-import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
+import { widget } from "../widget";
export type GroupCallLoaded = {
kind: "loaded";
@@ -38,14 +45,48 @@ export type GroupCallLoading = {
kind: "loading";
};
+export type GroupCallWaitForInvite = {
+ kind: "waitForInvite";
+ roomSummary: RoomSummary;
+};
+
+export type GroupCallCanKnock = {
+ kind: "canKnock";
+ roomSummary: RoomSummary;
+ knock: () => void;
+};
+
export type GroupCallStatus =
| GroupCallLoaded
| GroupCallLoadFailed
- | GroupCallLoading;
+ | GroupCallLoading
+ | GroupCallWaitForInvite
+ | GroupCallCanKnock;
-export interface GroupCallLoadState {
- error?: Error;
- groupCall?: GroupCall;
+export class CallTerminatedMessage extends Error {
+ /**
+ * @param messageBody The message explaining the kind of termination (kick, ban, knock reject, etc.) (translated)
+ */
+ public messageBody: string;
+ /**
+ * @param reason The user provided reason for the termination (kick/ban)
+ */
+ public reason?: string;
+ /**
+ *
+ * @param messageTitle The title of the call ended screen message (translated)
+ * @param messageBody The message explaining the kind of termination (kick, ban, knock reject, etc.) (translated)
+ * @param reason The user provided reason for the termination (kick/ban)
+ */
+ public constructor(
+ messageTitle: string,
+ messageBody: string,
+ reason?: string,
+ ) {
+ super(messageTitle);
+ this.messageBody = messageBody;
+ this.reason = reason;
+ }
}
export const useLoadGroupCall = (
@@ -53,36 +94,159 @@ export const useLoadGroupCall = (
roomIdOrAlias: string,
viaServers: string[],
): GroupCallStatus => {
- const { t } = useTranslation();
const [state, setState] = useState({ kind: "loading" });
+ const activeRoom = useRef();
+ const { t } = useTranslation();
+
+ const bannedError = useCallback(
+ (): CallTerminatedMessage =>
+ new CallTerminatedMessage(
+ t("group_call_loader.banned_heading"),
+ t("group_call_loader.banned_body"),
+ leaveReason(),
+ ),
+ [t],
+ );
+ const knockRejectError = useCallback(
+ (): CallTerminatedMessage =>
+ new CallTerminatedMessage(
+ t("group_call_loader.knock_reject_heading"),
+ t("group_call_loader.knock_reject_body"),
+ leaveReason(),
+ ),
+ [t],
+ );
+ const removeNoticeError = useCallback(
+ (): CallTerminatedMessage =>
+ new CallTerminatedMessage(
+ t("group_call_loader.call_ended_heading"),
+ t("group_call_loader.call_ended_body"),
+ leaveReason(),
+ ),
+ [t],
+ );
+
+ const leaveReason = (): string =>
+ activeRoom.current?.currentState
+ .getStateEvents(EventType.RoomMember, activeRoom.current?.myUserId)
+ ?.getContent().reason;
useEffect(() => {
+ const getRoomByAlias = async (alias: string): Promise => {
+ // We lowercase the localpart when we create the room, so we must lowercase
+ // it here too (we just do the whole alias). We can't do the same to room IDs
+ // though.
+ // Also, we explicitly look up the room alias here. We previously just tried to
+ // join anyway but the js-sdk recreates the room if you pass the alias for a
+ // room you're already joined to (which it probably ought not to).
+ let room: Room | null = null;
+ const lookupResult = await client.getRoomIdForAlias(alias.toLowerCase());
+ logger.info(`${alias} resolved to ${lookupResult.room_id}`);
+ room = client.getRoom(lookupResult.room_id);
+ if (!room) {
+ logger.info(`Room ${lookupResult.room_id} not found, joining.`);
+ room = await client.joinRoom(lookupResult.room_id, {
+ viaServers: lookupResult.servers,
+ });
+ } else {
+ logger.info(`Already in room ${lookupResult.room_id}, not rejoining.`);
+ }
+ return room;
+ };
+
+ const getRoomByKnocking = async (
+ roomId: string,
+ viaServers: string[],
+ onKnockSent: () => void,
+ ): Promise => {
+ let joinedRoom: Room | null = null;
+ await client.knockRoom(roomId, { viaServers });
+ onKnockSent();
+ const invitePromise = new Promise((resolve, reject) => {
+ client.on(
+ RoomEvent.MyMembership,
+ async (room, membership, prevMembership) => {
+ if (roomId !== room.roomId) return;
+ activeRoom.current = room;
+ if (membership === KnownMembership.Invite) {
+ await client.joinRoom(room.roomId, { viaServers });
+ joinedRoom = room;
+ logger.log("Auto-joined %s", room.roomId);
+ resolve();
+ }
+ if (membership === KnownMembership.Ban) reject(bannedError());
+ if (membership === KnownMembership.Leave)
+ reject(knockRejectError());
+ },
+ );
+ });
+ await invitePromise;
+ if (!joinedRoom) {
+ throw new Error("Failed to join room after knocking.");
+ }
+ return joinedRoom;
+ };
+
const fetchOrCreateRoom = async (): Promise => {
let room: Room | null = null;
if (roomIdOrAlias[0] === "#") {
- // We lowercase the localpart when we create the room, so we must lowercase
- // it here too (we just do the whole alias). We can't do the same to room IDs
- // though.
- // Also, we explicitly look up the room alias here. We previously just tried to
- // join anyway but the js-sdk recreates the room if you pass the alias for a
- // room you're already joined to (which it probably ought not to).
- const lookupResult = await client.getRoomIdForAlias(
- roomIdOrAlias.toLowerCase(),
- );
- logger.info(`${roomIdOrAlias} resolved to ${lookupResult.room_id}`);
- room = client.getRoom(lookupResult.room_id);
- if (!room) {
- logger.info(`Room ${lookupResult.room_id} not found, joining.`);
- room = await client.joinRoom(lookupResult.room_id, {
- viaServers: lookupResult.servers,
- });
- } else {
- logger.info(
- `Already in room ${lookupResult.room_id}, not rejoining.`,
+ const alias = roomIdOrAlias;
+ // The call uses a room alias
+ room = await getRoomByAlias(alias);
+ activeRoom.current = room;
+ } else {
+ // The call uses a room_id
+ const roomId = roomIdOrAlias;
+
+ // first try if the room already exists
+ // - in widget mode
+ // - in SPA mode if the user already joined the room
+ room = client.getRoom(roomId);
+ activeRoom.current = room ?? undefined;
+ if (room?.getMyMembership() === KnownMembership.Join) {
+ // room already joined so we are done here already.
+ return room!;
+ }
+ if (widget)
+ // in widget mode we never should reach this point. (getRoom should return the room.)
+ throw new Error(
+ "Room not found. The widget-api did not pass over the relevant room events/information.",
);
+
+ // If the room does not exist we first search for it with viaServers
+ const roomSummary = await client.getRoomSummary(roomId, viaServers);
+ if (room?.getMyMembership() === KnownMembership.Ban) {
+ throw bannedError();
+ } else {
+ if (roomSummary.join_rule === JoinRule.Public) {
+ room = await client.joinRoom(roomSummary.room_id, {
+ viaServers,
+ });
+ } else if (roomSummary.join_rule === JoinRule.Knock) {
+ let knock: () => void = () => {};
+ const userPressedAskToJoinPromise: Promise = new Promise(
+ (resolve) => {
+ if (roomSummary.membership !== KnownMembership.Knock) {
+ knock = resolve;
+ } else {
+ // resolve immediately if the user already knocked
+ resolve();
+ }
+ },
+ );
+ setState({ kind: "canKnock", roomSummary, knock });
+ await userPressedAskToJoinPromise;
+ room = await getRoomByKnocking(
+ roomSummary.room_id,
+ viaServers,
+ () => setState({ kind: "waitForInvite", roomSummary }),
+ );
+ } else {
+ throw new Error(
+ `Room ${roomSummary.room_id} is not joinable. This likely means, that the conference owner has changed the room settings to private.`,
+ );
+ }
}
- } else {
- room = await client.joinRoom(roomIdOrAlias, { viaServers });
}
logger.info(
@@ -95,6 +259,7 @@ export const useLoadGroupCall = (
const fetchOrCreateGroupCall = async (): Promise => {
const room = await fetchOrCreateRoom();
+ activeRoom.current = room;
logger.debug(`Fetched / joined room ${roomIdOrAlias}`);
const rtcSession = client.matrixRTC.getRoomSession(room);
@@ -119,11 +284,33 @@ export const useLoadGroupCall = (
}
};
- waitForClientSyncing()
- .then(fetchOrCreateGroupCall)
- .then((rtcSession) => setState({ kind: "loaded", rtcSession }))
- .catch((error) => setState({ kind: "failed", error }));
- }, [client, roomIdOrAlias, viaServers, t]);
+ const observeMyMembership = async (): Promise => {
+ await new Promise((_, reject) => {
+ client.on(RoomEvent.MyMembership, async (_, membership) => {
+ if (membership === KnownMembership.Leave) reject(removeNoticeError());
+ if (membership === KnownMembership.Ban) reject(bannedError());
+ });
+ });
+ };
+
+ if (state.kind === "loading") {
+ logger.log("Start loading group call");
+ waitForClientSyncing()
+ .then(fetchOrCreateGroupCall)
+ .then((rtcSession) => setState({ kind: "loaded", rtcSession }))
+ .then(observeMyMembership)
+ .catch((error) => setState({ kind: "failed", error }));
+ }
+ }, [
+ bannedError,
+ client,
+ knockRejectError,
+ removeNoticeError,
+ roomIdOrAlias,
+ state,
+ t,
+ viaServers,
+ ]);
return state;
};
diff --git a/src/settings/submit-rageshake.ts b/src/settings/submit-rageshake.ts
index 16c036e20..675314bc0 100644
--- a/src/settings/submit-rageshake.ts
+++ b/src/settings/submit-rageshake.ts
@@ -298,13 +298,13 @@ export function useRageshakeRequest(): (
const sendRageshakeRequest = useCallback(
(roomId: string, rageshakeRequestId: string) => {
+ // @ts-expect-error - org.matrix.rageshake_request is not part of `keyof TimelineEvents` but it is okay to sent a custom event.
client!.sendEvent(roomId, "org.matrix.rageshake_request", {
request_id: rageshakeRequestId,
});
},
[client],
);
-
return sendRageshakeRequest;
}
diff --git a/src/widget.ts b/src/widget.ts
index 32ab780e8..ad7156e30 100644
--- a/src/widget.ts
+++ b/src/widget.ts
@@ -122,6 +122,7 @@ export const widget = ((): WidgetHelpers | null => {
];
const receiveState = [
{ eventType: EventType.RoomMember },
+ { eventType: EventType.RoomEncryption },
{ eventType: EventType.GroupCallPrefix },
{ eventType: EventType.GroupCallMemberPrefix },
];
diff --git a/yarn.lock b/yarn.lock
index 08095e101..52fda21cb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1745,10 +1745,10 @@
dependencies:
"@bufbuild/protobuf" "^1.7.2"
-"@matrix-org/matrix-sdk-crypto-wasm@^4.6.0":
- version "4.6.0"
- resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-4.6.0.tgz#35224214c7638abbe2bc91fb4fa4fb022a1a2bf0"
- integrity sha512-v9PFWzSTWMlZKbyk3PPsZjUtOEQ7FIz5USD3lFRUWiS4pv0FOKR125VOUnR5Z/kAty57JXCHDAexCln3zE2Fww==
+"@matrix-org/matrix-sdk-crypto-wasm@^4.9.0":
+ version "4.9.0"
+ resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-4.9.0.tgz#9dfed83e33f760650596c4e5c520e5e4c53355d2"
+ integrity sha512-/bgA4QfE7qkK6GFr9hnhjAvRSebGrmEJxukU0ukbudZcYvbzymoBBM8j3HeULXZT8kbw8WH6z63txYTMCBSDOA==
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz":
version "3.2.14"
@@ -6298,13 +6298,12 @@ matrix-events-sdk@0.0.1:
resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd"
integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==
-"matrix-js-sdk@github:matrix-org/matrix-js-sdk#d55c6a36df539f6adacc335efe5b9be27c9cee4a":
- version "31.4.0"
- uid d55c6a36df539f6adacc335efe5b9be27c9cee4a
- resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d55c6a36df539f6adacc335efe5b9be27c9cee4a"
+"matrix-js-sdk@github:AndrewFerr/matrix-js-sdk#msc-3266-compliance":
+ version "32.0.0"
+ resolved "https://codeload.github.com/AndrewFerr/matrix-js-sdk/tar.gz/5cff292a0b6284714eab1b9498f422dd3d737ea1"
dependencies:
"@babel/runtime" "^7.12.5"
- "@matrix-org/matrix-sdk-crypto-wasm" "^4.6.0"
+ "@matrix-org/matrix-sdk-crypto-wasm" "^4.9.0"
another-json "^0.2.0"
bs58 "^5.0.0"
content-type "^1.0.4"