diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json
index a1eeec67..79d462b2 100644
--- a/public/locales/en-GB/app.json
+++ b/public/locales/en-GB/app.json
@@ -60,8 +60,12 @@
"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_banned_heading": "Not allowed to join",
+ "group_call_loader_banned_message": "You are banned from this room and therefore cannot send a request to join.",
"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_reject_message": "The room members declined your request to join.",
+ "group_call_loader_rejected_heading": "Not allowed to join",
"hangup_button_label": "End call",
"header_label": "Element Call Home",
"header_participants_label": "Participants",
@@ -150,5 +154,8 @@
"sfu_participant_local": "You",
"volume": "Volume"
},
+ "wait_for_invite": {
+ "wait_message": "Waiting for the meeting host to let you in."
+ },
"waiting_for_participants": "Waiting for other participants…"
}
diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx
index 78eec7fe..4df2f39e 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")}
-
- )}
+ {!confineToRoom &&
+ (location.pathname === "/" ? (
+
+ ) : (
+
+ {t("return_home_button")}
+
+ ))}
);
};
diff --git a/src/room/GroupCallLoader.tsx b/src/room/GroupCallLoader.tsx
index 8a6628ba..f6b30a00 100644
--- a/src/room/GroupCallLoader.tsx
+++ b/src/room/GroupCallLoader.tsx
@@ -17,19 +17,23 @@ limitations under the License.
import { ReactNode, useCallback } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
-import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { MatrixError } from "matrix-js-sdk";
import { useHistory } from "react-router-dom";
import { Heading, Link, Text } from "@vector-im/compound-web";
-import { useLoadGroupCall } from "./useLoadGroupCall";
+import {
+ useLoadGroupCall,
+ GroupCallStatus,
+ KnockRejectError,
+ BannedError,
+} from "./useLoadGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
interface Props {
client: MatrixClient;
roomIdOrAlias: string;
viaServers: string[];
- children: (rtcSession: MatrixRTCSession) => ReactNode;
+ children: (groupCallState: GroupCallStatus) => ReactNode;
}
export function GroupCallLoader({
@@ -51,14 +55,15 @@ export function GroupCallLoader({
);
switch (groupCallState.kind) {
+ case "loaded":
+ case "waitForInvite":
+ 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 (
@@ -72,6 +77,26 @@ export function GroupCallLoader({
);
+ } else if (groupCallState.error instanceof KnockRejectError) {
+ return (
+
+ {t("group_call_loader_rejected_heading")}
+ {groupCallState.error.message}
+
+ {t("common.home")}
+
+
+ );
+ } else if (groupCallState.error instanceof BannedError) {
+ return (
+
+ {t("group_call_loader_banned_heading")}
+ {t("group_call_loader_banned_message")}
+
+ {t("common.home")}
+
+
+ );
} else {
return ;
}
diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx
index 0e93b547..b3b9242d 100644
--- a/src/room/RoomPage.tsx
+++ b/src/room/RoomPage.tsx
@@ -15,7 +15,6 @@ 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 { useClientLegacy } from "../ClientContext";
@@ -30,6 +29,8 @@ import { HomePage } from "../home/HomePage";
import { platform } from "../Platform";
import { AppSelectionModal } from "./AppSelectionModal";
import { widget } from "../widget";
+import { GroupCallStatus } from "./useLoadGroupCall";
+import { WaitForInviteView } from "./WaitForInviteView";
export const RoomPage: FC = () => {
const {
@@ -78,17 +79,30 @@ export const RoomPage: FC = () => {
]);
const groupCallView = useCallback(
- (rtcSession: MatrixRTCSession) => (
-
- ),
+ (groupCallState: GroupCallStatus) => {
+ switch (groupCallState.kind) {
+ case "loaded":
+ return (
+
+ );
+ case "waitForInvite":
+ return (
+
+ );
+ }
+ },
[client, passwordlessUser, confineToRoom, preload, hideHeader, skipLobby],
);
diff --git a/src/room/WaitForInviteView.tsx b/src/room/WaitForInviteView.tsx
new file mode 100644
index 00000000..c98483d8
--- /dev/null
+++ b/src/room/WaitForInviteView.tsx
@@ -0,0 +1,95 @@
+/*
+Copyright 2022-2023 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { FC, useCallback, useState } from "react";
+import { useTranslation } from "react-i18next";
+import classNames from "classnames";
+import { MatrixClient } from "matrix-js-sdk";
+
+import styles from "./LobbyView.module.css";
+import inCallStyles from "./InCallView.module.css";
+import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
+import { useLocationNavigation } from "../useLocationNavigation";
+import { SettingsButton } from "../button/Button";
+import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
+import { RoomSummary } from "./useLoadGroupCall";
+
+interface Props {
+ hideHeader: boolean;
+ client: MatrixClient;
+ roomSummary: RoomSummary;
+}
+
+export const WaitForInviteView: FC = ({
+ hideHeader,
+ client,
+ roomSummary,
+}) => {
+ const { t } = useTranslation();
+ useLocationNavigation();
+
+ const [settingsModalOpen, setSettingsModalOpen] = useState(false);
+ const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
+
+ const openSettings = useCallback(
+ () => setSettingsModalOpen(true),
+ [setSettingsModalOpen],
+ );
+ const closeSettings = useCallback(
+ () => setSettingsModalOpen(false),
+ [setSettingsModalOpen],
+ );
+
+ // TODO: Unify this component with InCallView, so we can get slick joining
+ // animations and don't have to feel bad about reusing its CSS
+ return (
+ <>
+
+ {!hideHeader && (
+
+
+
+
+
+
+ )}
+
+ {t("wait_for_invite.wait_message")}
+
+
+
+
+
+
+
+ {client && (
+
+ )}
+ >
+ );
+};
diff --git a/src/room/useLoadGroupCall.ts b/src/room/useLoadGroupCall.ts
index 920cb8bc..1cf33ffd 100644
--- a/src/room/useLoadGroupCall.ts
+++ b/src/room/useLoadGroupCall.ts
@@ -18,10 +18,12 @@ import { useState, useEffect } from "react";
import { logger } from "matrix-js-sdk/src/logger";
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 { RoomEvent, type Room } from "matrix-js-sdk/src/models/room";
+import { KnownMembership, Membership, RoomType } 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";
export type GroupCallLoaded = {
@@ -38,51 +40,140 @@ export type GroupCallLoading = {
kind: "loading";
};
+export type GroupCallWaitForInvite = {
+ kind: "waitForInvite";
+ roomSummary: RoomSummary;
+};
+
export type GroupCallStatus =
| GroupCallLoaded
| GroupCallLoadFailed
- | GroupCallLoading;
-
+ | GroupCallLoading
+ | GroupCallWaitForInvite;
export interface GroupCallLoadState {
- error?: Error;
+ error?: Error | KnockRejectError;
groupCall?: GroupCall;
}
+export class KnockRejectError extends Error {}
+export class BannedError extends Error {}
+
+// RoomSummary from the js-sdk (this is not public so we copy it here)
+export interface RoomSummary {
+ room_id: string;
+ name?: string;
+ avatar_url?: string;
+ topic?: string;
+ canonical_alias?: string;
+ aliases?: string[];
+ world_readable: boolean;
+ guest_can_join: boolean;
+ num_joined_members: number;
+ join_rule?: JoinRule.Knock | JoinRule.Public; // Added by MSC2403
+ room_type?: RoomType;
+ membership?: Membership;
+ is_encrypted: boolean;
+}
+
export const useLoadGroupCall = (
client: MatrixClient,
roomIdOrAlias: string,
viaServers: string[],
): GroupCallStatus => {
- const { t } = useTranslation();
const [state, setState] = useState({ kind: "loading" });
+ const { t } = useTranslation();
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[],
+ ): Promise => {
+ let joinedRoom: Room | null = null;
+ await client.knockRoom(roomId);
+
+ const invitePromise = new Promise((resolve, reject) => {
+ client.on(
+ RoomEvent.MyMembership,
+ async (room, membership, prevMembership) => {
+ if (membership === KnownMembership.Invite) {
+ await client.joinRoom(room.roomId, { viaServers });
+ joinedRoom = room;
+ logger.log("Auto-joined %s", room.roomId);
+ resolve();
+ }
+ if (
+ membership === KnownMembership.Ban ||
+ membership === KnownMembership.Leave
+ ) {
+ // also resolve in case of rejection
+ // we will check if joining worked in the next step
+ reject(
+ new KnockRejectError(t("group_call_loader_reject_message")),
+ );
+ }
+ },
+ );
+ });
+ 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.`,
- );
- }
+ // The call uses a room alias
+ room = await getRoomByAlias(roomIdOrAlias);
} else {
- room = await client.joinRoom(roomIdOrAlias, { viaServers });
+ // The call uses a room_id
+ const roomSummary = await client.getRoomSummary(
+ roomIdOrAlias,
+ viaServers,
+ );
+ room = client.getRoom(roomSummary.room_id);
+ if (room?.getMyMembership() === KnownMembership.Join) {
+ // room already joined so we are done here already.
+ return room;
+ } else if (room?.getMyMembership() === KnownMembership.Ban) {
+ throw new BannedError();
+ } else {
+ if (roomSummary.join_rule === JoinRule.Public) {
+ room = await client.joinRoom(roomSummary.room_id, {
+ viaServers,
+ });
+ } else if (roomSummary.join_rule === JoinRule.Knock) {
+ setState({ kind: "waitForInvite", roomSummary });
+ room = await getRoomByKnocking(roomSummary.room_id, viaServers);
+ } else {
+ throw new Error(
+ `Room ${roomIdOrAlias} is not joinable. This likely means, that the conference owner has changed the room settings to private.`,
+ );
+ }
+ }
}
logger.info(
@@ -119,11 +210,15 @@ export const useLoadGroupCall = (
}
};
- waitForClientSyncing()
- .then(fetchOrCreateGroupCall)
- .then((rtcSession) => setState({ kind: "loaded", rtcSession }))
- .catch((error) => setState({ kind: "failed", error }));
- }, [client, roomIdOrAlias, viaServers, t]);
+ if (state.kind === "loading") {
+ logger.log("Start loading group call");
+ waitForClientSyncing()
+ .then(fetchOrCreateGroupCall)
+ .then((rtcSession) => setState({ kind: "loaded", rtcSession }))
+ .catch((error) => setState({ kind: "failed", error }));
+ }
+ }, [client, roomIdOrAlias, state, t, viaServers]);
+ // state === undefined is used to make sure we only run the effect once. But outside the hook it is equivalent to "loading".
return state;
};