diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 79d462b2..d5f7f82f 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -81,8 +81,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/room/GroupCallLoader.tsx b/src/room/GroupCallLoader.tsx index f6b30a00..9ceb77ff 100644 --- a/src/room/GroupCallLoader.tsx +++ b/src/room/GroupCallLoader.tsx @@ -57,6 +57,7 @@ export function GroupCallLoader({ switch (groupCallState.kind) { case "loaded": case "waitForInvite": + case "canKnock": return <>{children(groupCallState)}; case "loading": return ( diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 36ebc3a4..d89b006b 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -37,7 +37,7 @@ 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"; @@ -64,6 +64,7 @@ interface Props { skipLobby: boolean; hideHeader: boolean; rtcSession: MatrixRTCSession; + muteStates: MuteStates; } export const GroupCallView: FC = ({ @@ -74,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, setAutoMuteHappened] = useState(false); + useEffect(() => { + if (autoMuteHappened) return; + if (memberships.length >= MUTE_PARTICIPANT_COUNT) { + muteStates.audio.setEnabled?.(false); + setAutoMuteHappened(true); + } + }, [autoMuteHappened, memberships, muteStates.audio]); + useEffect(() => { window.rtcSession = rtcSession; return () => { @@ -122,7 +136,6 @@ export const GroupCallView: FC = ({ const latestDevices = useRef(); latestDevices.current = deviceContext; - const muteStates = useMuteStates(memberships.length); const latestMuteStates = useRef(); latestMuteStates.current = muteStates; diff --git a/src/room/LobbyView.module.css b/src/room/LobbyView.module.css index 168ce69e..1aa54c60 100644 --- a/src/room/LobbyView.module.css +++ b/src/room/LobbyView.module.css @@ -24,6 +24,10 @@ limitations under the License. overflow: hidden; height: 100%; } +.wait { + color: black !important; + background-color: rgba(255, 255, 255, 0.67) !important; +} @media (max-width: 500px) { .join { diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index df3496f1..370c3c59 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"; @@ -43,10 +43,12 @@ interface Props { matrixInfo: MatrixInfo; muteStates: MuteStates; onEnter: () => void; + enterLabel?: JSX.Element; confineToRoom: boolean; hideHeader: boolean; participantCount: number; onShareClick: (() => void) | null; + waitingForInvite?: boolean; } export const LobbyView: FC = ({ @@ -54,10 +56,12 @@ export const LobbyView: FC = ({ matrixInfo, muteStates, onEnter, + enterLabel, confineToRoom, hideHeader, participantCount, onShareClick, + waitingForInvite, }) => { const { t } = useTranslation(); useLocationNavigation(); @@ -117,12 +121,18 @@ export const LobbyView: FC = ({
{!recentsButtonInFooter && recentsButton} diff --git a/src/room/MuteStates.ts b/src/room/MuteStates.ts index db1fb22a..ed1eb804 100644 --- a/src/room/MuteStates.ts +++ b/src/room/MuteStates.ts @@ -20,10 +20,11 @@ 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; +// TODO set back to 8. for demo purposes we use 1 here. +export const MUTE_PARTICIPANT_COUNT = 1; interface DeviceAvailable { enabled: boolean; @@ -51,26 +52,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 544f100b..b8e3a900 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -16,6 +16,8 @@ limitations under the License. import { FC, useEffect, useState, useCallback, ReactNode } from "react"; 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,7 +32,10 @@ import { platform } from "../Platform"; import { AppSelectionModal } from "./AppSelectionModal"; import { widget } from "../widget"; import { GroupCallStatus } from "./useLoadGroupCall"; -import { WaitForInviteView } from "./WaitForInviteView"; +import { LobbyView } from "./LobbyView"; +import { E2eeType } from "../e2ee/e2eeType"; +import { useProfile } from "../profile/useProfile"; +import { useMuteStates } from "./MuteStates"; export const RoomPage: FC = () => { const { @@ -41,7 +46,7 @@ export const RoomPage: FC = () => { displayName, skipLobby, } = useUrlParams(); - + const { t } = useTranslation(); const { roomAlias, roomId, viaServers } = useRoomIdentifier(); const roomIdOrAlias = roomId ?? roomAlias; @@ -49,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 @@ -78,6 +80,12 @@ 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( (groupCallState: GroupCallStatus) => { switch (groupCallState.kind) { @@ -91,19 +99,66 @@ export const RoomPage: FC = () => { preload={preload} skipLobby={skipLobby} hideHeader={hideHeader} + muteStates={muteStates} /> ); case "waitForInvite": + case "canKnock": { + const knock = + groupCallState.kind === "canKnock" ? groupCallState.knock : null; + const label = + 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={0} + muteStates={muteStates} + onShareClick={null} /> ); + } } }, - [client, passwordlessUser, confineToRoom, preload, hideHeader, skipLobby], + [ + client, + passwordlessUser, + confineToRoom, + preload, + skipLobby, + hideHeader, + muteStates, + t, + userDisplayName, + avatarUrl, + ], ); let content: ReactNode; diff --git a/src/room/useLoadGroupCall.ts b/src/room/useLoadGroupCall.ts index 32fa85fb..b65ceb66 100644 --- a/src/room/useLoadGroupCall.ts +++ b/src/room/useLoadGroupCall.ts @@ -46,11 +46,18 @@ export type GroupCallWaitForInvite = { roomSummary: RoomSummary; }; +export type GroupCallCanKnock = { + kind: "canKnock"; + roomSummary: RoomSummary; + knock: () => void; +}; + export type GroupCallStatus = | GroupCallLoaded | GroupCallLoadFailed | GroupCallLoading - | GroupCallWaitForInvite; + | GroupCallWaitForInvite + | GroupCallCanKnock; export interface GroupCallLoadState { error?: Error | KnockRejectError; groupCall?: GroupCall; @@ -73,7 +80,7 @@ export interface RoomSummary { join_rule?: JoinRule.Knock | JoinRule.Public; // Added by MSC2403 room_type?: RoomType; membership?: Membership; - is_encrypted: boolean; + "im.nheko.summary.encryption"?: boolean; } export const useLoadGroupCall = ( @@ -110,10 +117,11 @@ export const useLoadGroupCall = ( const getRoomByKnocking = async ( roomId: string, viaServers: string[], + onKnockSent: () => void, ): Promise => { let joinedRoom: Room | null = null; await client.knockRoom(roomId); - + onKnockSent(); const invitePromise = new Promise((resolve, reject) => { client.on( RoomEvent.MyMembership, @@ -168,8 +176,12 @@ export const useLoadGroupCall = ( "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 the room does not exist we first search for it with viaServers + // We need to cast it to our own RoomSummary interface since we use the im.nheko.summary.encryption field. + const roomSummary = (await client.getRoomSummary( + roomId, + viaServers, + )) as unknown as RoomSummary; if (room?.getMyMembership() === KnownMembership.Ban) { throw new BannedError(); } else { @@ -178,8 +190,24 @@ export const useLoadGroupCall = ( viaServers, }); } else if (roomSummary.join_rule === JoinRule.Knock) { - setState({ kind: "waitForInvite", roomSummary }); - room = await getRoomByKnocking(roomSummary.room_id, viaServers); + 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.`,