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", () => {