From bb71c86c8a0265efdc346f8391abe21204e53e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= <simon.bra.ag@gmail.com> Date: Fri, 7 Oct 2022 22:16:35 +0200 Subject: [PATCH] Add Element Call participant limit (#9358) --- src/IConfigOptions.ts | 1 + src/SdkConfig.ts | 1 + src/components/views/messages/CallEvent.tsx | 18 ++++++--- src/components/views/voip/CallView.tsx | 27 ++++++++----- src/hooks/useCall.ts | 22 ++++++++++- src/i18n/strings/en_EN.json | 5 ++- src/toasts/IncomingCallToast.tsx | 40 +++++++++++++++----- test/components/views/voip/CallView-test.tsx | 19 ++++++++++ 8 files changed, 107 insertions(+), 26 deletions(-) diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index d8c26909df..91391fc2a9 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -119,6 +119,7 @@ export interface IConfigOptions { element_call: { url?: string; use_exclusively?: boolean; + participant_limit?: number; brand?: string; }; diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index 2e1e4a4472..235ada7382 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -33,6 +33,7 @@ export const DEFAULTS: IConfigOptions = { element_call: { url: "https://call.element.io", use_exclusively: false, + participant_limit: 8, brand: "Element Call", }, diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index 151adaa9f5..6680345bbc 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -20,7 +20,7 @@ import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { Call, ConnectionState } from "../../../models/Call"; import { _t } from "../../../languageHandler"; -import { useCall, useConnectionState, useParticipants } from "../../../hooks/useCall"; +import { useCall, useConnectionState, useJoinCallButtonDisabledTooltip, useParticipants } from "../../../hooks/useCall"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../../dispatcher/actions"; @@ -28,9 +28,9 @@ import type { ButtonEvent } from "../elements/AccessibleButton"; import MemberAvatar from "../avatars/MemberAvatar"; import { LiveContentSummary, LiveContentType } from "../rooms/LiveContentSummary"; import FacePile from "../elements/FacePile"; -import AccessibleButton from "../elements/AccessibleButton"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { CallDuration, CallDurationFromEvent } from "../voip/CallDuration"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; const MAX_FACES = 8; @@ -39,6 +39,8 @@ interface ActiveCallEventProps { participants: Set<RoomMember>; buttonText: string; buttonKind: string; + buttonTooltip?: string; + buttonDisabled?: boolean; onButtonClick: ((ev: ButtonEvent) => void) | null; } @@ -49,6 +51,8 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>( participants, buttonText, buttonKind, + buttonDisabled, + buttonTooltip, onButtonClick, }, ref, @@ -80,14 +84,15 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>( <FacePile members={facePileMembers} faceSize={24} overflow={facePileOverflow} /> </div> <CallDurationFromEvent mxEvent={mxEvent} /> - <AccessibleButton + <AccessibleTooltipButton className="mx_CallEvent_button" kind={buttonKind} - disabled={onButtonClick === null} + disabled={onButtonClick === null || buttonDisabled} onClick={onButtonClick} + tooltip={buttonTooltip} > { buttonText } - </AccessibleButton> + </AccessibleTooltipButton> </div> </div>; }, @@ -101,6 +106,7 @@ interface ActiveLoadedCallEventProps { const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxEvent, call }, ref) => { const connectionState = useConnectionState(call); const participants = useParticipants(call); + const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call); const connect = useCallback((ev: ButtonEvent) => { ev.preventDefault(); @@ -132,6 +138,8 @@ const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxE participants={participants} buttonText={buttonText} buttonKind={buttonKind} + buttonDisabled={Boolean(joinCallButtonDisabledTooltip)} + buttonTooltip={joinCallButtonDisabledTooltip} onButtonClick={onButtonClick} />; }); diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index fbab581e0f..65439e56c9 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -22,7 +22,7 @@ import { defer, IDeferred } from "matrix-js-sdk/src/utils"; import type { Room } from "matrix-js-sdk/src/models/room"; import type { ConnectionState } from "../../../models/Call"; import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call"; -import { useCall, useConnectionState, useParticipants } from "../../../hooks/useCall"; +import { useCall, useConnectionState, useJoinCallButtonDisabledTooltip, useParticipants } from "../../../hooks/useCall"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AppTile from "../elements/AppTile"; import { _t } from "../../../languageHandler"; @@ -35,7 +35,7 @@ import IconizedContextMenu, { } from "../context_menus/IconizedContextMenu"; import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; import { Alignment } from "../elements/Tooltip"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import FacePile from "../elements/FacePile"; import MemberAvatar from "../avatars/MemberAvatar"; @@ -110,10 +110,11 @@ const MAX_FACES = 8; interface LobbyProps { room: Room; connect: () => Promise<void>; + joinCallButtonDisabledTooltip?: string; children?: ReactNode; } -export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => { +export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabledTooltip, connect, children }) => { const [connecting, setConnecting] = useState(false); const me = useMemo(() => room.getMember(room.myUserId)!, [room]); const videoRef = useRef<HTMLVideoElement>(null); @@ -233,14 +234,15 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => { /> </div> </div> - <AccessibleButton + <AccessibleTooltipButton className="mx_CallView_connectButton" kind="primary" - disabled={connecting} + disabled={connecting || Boolean(joinCallButtonDisabledTooltip)} onClick={onConnectClick} - > - { _t("Join") } - </AccessibleButton> + title={_t("Join")} + label={_t("Join")} + tooltip={connecting ? _t("Connecting") : joinCallButtonDisabledTooltip} + /> </div>; }; @@ -321,6 +323,7 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => { const cli = useContext(MatrixClientContext); const connected = isConnected(useConnectionState(call)); const participants = useParticipants(call); + const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call); const connect = useCallback(async () => { // Disconnect from any other active calls first, since we don't yet support holding @@ -344,7 +347,13 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => { </div>; } - lobby = <Lobby room={room} connect={connect}>{ facePile }</Lobby>; + lobby = <Lobby + room={room} + connect={connect} + joinCallButtonDisabledTooltip={joinCallButtonDisabledTooltip} + > + { facePile } + </Lobby>; } return <div className="mx_CallView"> diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts index e5bbfe563f..178514f262 100644 --- a/src/hooks/useCall.ts +++ b/src/hooks/useCall.ts @@ -17,11 +17,13 @@ limitations under the License. import { useState, useCallback } from "react"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import type { Call, ConnectionState, ElementCall, Layout } from "../models/Call"; +import { Call, ConnectionState, ElementCall, Layout } from "../models/Call"; import { useTypedEventEmitterState } from "./useEventEmitter"; import { CallEvent } from "../models/Call"; import { CallStore, CallStoreEvent } from "../stores/CallStore"; import { useEventEmitter } from "./useEventEmitter"; +import SdkConfig, { DEFAULTS } from "../SdkConfig"; +import { _t } from "../languageHandler"; export const useCall = (roomId: string): Call | null => { const [call, setCall] = useState(() => CallStore.instance.getCall(roomId)); @@ -45,6 +47,24 @@ export const useParticipants = (call: Call): Set<RoomMember> => useCallback(state => state ?? call.participants, [call]), ); +export const useFull = (call: Call): boolean => { + const participants = useParticipants(call); + + return ( + participants.size + >= (SdkConfig.get("element_call").participant_limit ?? DEFAULTS.element_call.participant_limit) + ); +}; + +export const useJoinCallButtonDisabledTooltip = (call: Call): string | null => { + const isFull = useFull(call); + const state = useConnectionState(call); + + if (state === ConnectionState.Connecting) return _t("Connecting"); + if (isFull) return _t("Sorry — this call is currently full"); + return null; +}; + export const useLayout = (call: ElementCall): Layout => useTypedEventEmitterState( call, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 8da63749df..7a6b984563 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -797,10 +797,10 @@ "Don't miss a reply": "Don't miss a reply", "Notifications": "Notifications", "Enable desktop notifications": "Enable desktop notifications", + "Join": "Join", "Unknown room": "Unknown room", "Video call started": "Video call started", "Video": "Video", - "Join": "Join", "Close": "Close", "Unknown caller": "Unknown caller", "Voice call": "Voice call", @@ -1014,6 +1014,8 @@ "When rooms are upgraded": "When rooms are upgraded", "My Ban List": "My Ban List", "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!", + "Connecting": "Connecting", + "Sorry — this call is currently full": "Sorry — this call is currently full", "Create account": "Create account", "You made it!": "You made it!", "Find and invite your friends": "Find and invite your friends", @@ -1070,7 +1072,6 @@ "You held the call <a>Switch</a>": "You held the call <a>Switch</a>", "You held the call <a>Resume</a>": "You held the call <a>Resume</a>", "%(peerName)s held the call": "%(peerName)s held the call", - "Connecting": "Connecting", "Dialpad": "Dialpad", "Mute the microphone": "Mute the microphone", "Unmute the microphone": "Unmute the microphone", diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index 5f7bb4d2a7..c8eff9dc82 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.tsx @@ -19,7 +19,6 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { _t } from '../languageHandler'; import RoomAvatar from '../components/views/avatars/RoomAvatar'; -import AccessibleButton from '../components/views/elements/AccessibleButton'; import { MatrixClientPeg } from "../MatrixClientPeg"; import defaultDispatcher from "../dispatcher/dispatcher"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; @@ -31,14 +30,34 @@ import { LiveContentSummaryWithCall, LiveContentType, } from "../components/views/rooms/LiveContentSummary"; -import { useCall } from "../hooks/useCall"; +import { useCall, useJoinCallButtonDisabledTooltip } from "../hooks/useCall"; import { useRoomState } from "../hooks/useRoomState"; import { ButtonEvent } from "../components/views/elements/AccessibleButton"; import { useDispatcher } from "../hooks/useDispatcher"; import { ActionPayload } from "../dispatcher/payloads"; +import { Call } from "../models/Call"; export const getIncomingCallToastKey = (stateKey: string) => `call_${stateKey}`; +interface JoinCallButtonWithCallProps { + onClick: (e: ButtonEvent) => void; + call: Call; +} + +function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps) { + const tooltip = useJoinCallButtonDisabledTooltip(call); + + return <AccessibleTooltipButton + className="mx_IncomingCallToast_joinButton" + onClick={onClick} + disabled={Boolean(tooltip)} + tooltip={tooltip} + kind="primary" + > + { _t("Join") } + </AccessibleTooltipButton>; +} + interface Props { callEvent: MatrixEvent; } @@ -114,13 +133,16 @@ export function IncomingCallToast({ callEvent }: Props) { /> } </div> - <AccessibleButton - className="mx_IncomingCallToast_joinButton" - onClick={onJoinClick} - kind="primary" - > - { _t("Join") } - </AccessibleButton> + { call + ? <JoinCallButtonWithCall onClick={onJoinClick} call={call} /> + : <AccessibleTooltipButton + className="mx_IncomingCallToast_joinButton" + onClick={onJoinClick} + kind="primary" + > + { _t("Join") } + </AccessibleTooltipButton> + } </div> <AccessibleTooltipButton className="mx_IncomingCallToast_closeButton" diff --git a/test/components/views/voip/CallView-test.tsx b/test/components/views/voip/CallView-test.tsx index 4d1bf6afc7..a8da45d5de 100644 --- a/test/components/views/voip/CallView-test.tsx +++ b/test/components/views/voip/CallView-test.tsx @@ -22,6 +22,7 @@ import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { Widget } from "matrix-widget-api"; +import "@testing-library/jest-dom"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { ClientWidgetApi } from "matrix-widget-api"; @@ -38,6 +39,7 @@ import { CallView as _CallView } from "../../../../src/components/views/voip/Cal import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; import { CallStore } from "../../../../src/stores/CallStore"; import { Call, ConnectionState } from "../../../../src/models/Call"; +import SdkConfig from "../../../../src/SdkConfig"; const CallView = wrapInMatrixClientContext(_CallView); @@ -163,6 +165,23 @@ describe("CallLobby", () => { fireEvent.click(screen.getByRole("button", { name: "Join" })); await waitFor(() => expect(connectSpy).toHaveBeenCalled(), { interval: 1 }); }); + + it("disables join button when the participant limit has been exceeded", async () => { + const bob = mkRoomMember(room.roomId, "@bob:example.org"); + const carol = mkRoomMember(room.roomId, "@carol:example.org"); + + SdkConfig.put({ + "element_call": { participant_limit: 2, url: "", use_exclusively: false, brand: "Element Call" }, + }); + call.participants = new Set([bob, carol]); + + await renderView(); + const connectSpy = jest.spyOn(call, "connect"); + const joinButton = screen.getByRole("button", { name: "Join" }); + expect(joinButton).toHaveAttribute("aria-disabled", "true"); + fireEvent.click(joinButton); + await waitFor(() => expect(connectSpy).not.toHaveBeenCalled(), { interval: 1 }); + }); }); describe("without an existing call", () => {