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.", "full_screen_view_h1": "<0>Oops, something's gone wrong.", - "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")} - - )} + {!confineToRoom && + (location.pathname === "/" ? ( + + ) : ( + + {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 = ({
{!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"