From 620c3f5d34110b6930283bbd1c769a28448e030e Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 2 Apr 2024 23:40:33 +0200 Subject: [PATCH] Add joining with knock room creation flow. Also add `WaitForInviteView` after knocking. And appropriate error views when knock failed or gets rejected. Signed-off-by: Timo K --- public/locales/en-GB/app.json | 7 ++ src/FullScreenView.tsx | 40 ++++---- src/room/GroupCallLoader.tsx | 35 ++++++- src/room/RoomPage.tsx | 38 +++++--- src/room/WaitForInviteView.tsx | 95 +++++++++++++++++++ src/room/useLoadGroupCall.ts | 161 ++++++++++++++++++++++++++------- 6 files changed, 307 insertions(+), 69 deletions(-) create mode 100644 src/room/WaitForInviteView.tsx 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.", "full_screen_view_h1": "<0>Oops, something's gone wrong.", + "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; };