/* Copyright 2022-2024 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ import { useState, useEffect, useRef, useCallback } from "react"; import { logger } from "matrix-js-sdk/src/logger"; 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 { 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/src/matrix"; import { useTranslation } from "react-i18next"; import { widget } from "../widget"; export type GroupCallLoaded = { kind: "loaded"; rtcSession: MatrixRTCSession; }; export type GroupCallLoadFailed = { kind: "failed"; error: Error; }; 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 | GroupCallWaitForInvite | GroupCallCanKnock; 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 = ( client: MatrixClient, roomIdOrAlias: string, viaServers: string[], ): GroupCallStatus => { 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 => { await client.knockRoom(roomId, { viaServers }); onKnockSent(); return await new Promise((resolve, reject) => { client.on( RoomEvent.MyMembership, (room, membership, prevMembership): void => { if (roomId !== room.roomId) return; activeRoom.current = room; if ( membership === KnownMembership.Invite && prevMembership === KnownMembership.Knock ) { client.joinRoom(room.roomId, { viaServers }).then((room) => { logger.log("Auto-joined %s", room.roomId); resolve(room); }, reject); } if (membership === KnownMembership.Ban) reject(bannedError()); if (membership === KnownMembership.Leave) reject(knockRejectError()); }, ); }); }; const fetchOrCreateRoom = async (): Promise => { let room: Room | null = null; if (roomIdOrAlias[0] === "#") { 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; const membership = room?.getMyMembership(); if (membership === 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 (membership === KnownMembership.Ban) { throw bannedError(); } else if (membership === KnownMembership.Invite) { room = await client.joinRoom(roomId, { viaServers, }); } else { // If the room does not exist we first search for it with viaServers let roomSummary: RoomSummary | undefined = undefined; try { roomSummary = await client.getRoomSummary(roomId, viaServers); } catch (error) { // If the room summary endpoint is not supported we let it be undefined and treat this case like // `JoinRule.Public`. // This is how the logic was done before: "we expect any room id passed to EC // to be for a public call" Which is definitely not ideal but worth a try if fetching // the summary crashes. logger.warn( `Could not load room summary to decide whether we want to join or knock. EC will fallback to join as if this would be a public room. Reach out to your homeserver admin to ask them about supporting the \`/summary\` endpoint (im.nheko.summary):`, error, ); } if ( roomSummary === undefined || roomSummary.join_rule === JoinRule.Public ) { room = await client.joinRoom(roomId, { viaServers, }); } else if (roomSummary.join_rule === JoinRule.Knock) { // bind room summary in this scope so we have it stored in a binding of type `RoomSummary` // instead of `RoomSummary | undefined`. Because we use it in a promise the linter does not accept // the type check from the if condition above. const _roomSummary = roomSummary; 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: _roomSummary, knock }); await userPressedAskToJoinPromise; room = await getRoomByKnocking( roomSummary.room_id, viaServers, () => setState({ kind: "waitForInvite", roomSummary: _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.`, ); } } } logger.info( `Joined ${roomIdOrAlias}, waiting room to be ready for group calls`, ); await client.waitUntilRoomReadyForGroupCalls(room.roomId); logger.info(`${roomIdOrAlias}, is ready for group calls`); return room; }; const fetchOrCreateGroupCall = async (): Promise => { const room = await fetchOrCreateRoom(); activeRoom.current = room; logger.debug(`Fetched / joined room ${roomIdOrAlias}`); const rtcSession = client.matrixRTC.getRoomSession(room); return rtcSession; }; const waitForClientSyncing = async (): Promise => { if (client.getSyncState() !== SyncState.Syncing) { logger.debug( "useLoadGroupCall: waiting for client to start syncing...", ); await new Promise((resolve) => { const onSync = (): void => { if (client.getSyncState() === SyncState.Syncing) { client.off(ClientEvent.Sync, onSync); return resolve(); } }; client.on(ClientEvent.Sync, onSync); }); logger.debug("useLoadGroupCall: client is now syncing."); } }; const observeMyMembership = async (): Promise => { await new Promise((_, reject) => { client.on(RoomEvent.MyMembership, (_, 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; };