Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Element Call participant limit #9358

Merged
merged 11 commits into from Oct 7, 2022
1 change: 1 addition & 0 deletions src/IConfigOptions.ts
Expand Up @@ -119,6 +119,7 @@ export interface IConfigOptions {
element_call: {
url?: string;
use_exclusively?: boolean;
participant_limit?: number;
brand?: string;
};

Expand Down
1 change: 1 addition & 0 deletions src/SdkConfig.ts
Expand Up @@ -33,6 +33,7 @@ export const DEFAULTS: IConfigOptions = {
element_call: {
url: "https://call.element.io",
use_exclusively: false,
participant_limit: 8,
brand: "Element Call",
},

Expand Down
18 changes: 13 additions & 5 deletions src/components/views/messages/CallEvent.tsx
Expand Up @@ -20,17 +20,17 @@ 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";
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;

Expand All @@ -39,6 +39,8 @@ interface ActiveCallEventProps {
participants: Set<RoomMember>;
buttonText: string;
buttonKind: string;
buttonTooltip?: string;
buttonDisabled?: boolean;
onButtonClick: ((ev: ButtonEvent) => void) | null;
}

Expand All @@ -49,6 +51,8 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
participants,
buttonText,
buttonKind,
buttonDisabled,
buttonTooltip,
onButtonClick,
},
ref,
Expand Down Expand Up @@ -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>;
},
Expand All @@ -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();
Expand Down Expand Up @@ -132,6 +138,8 @@ const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxE
participants={participants}
buttonText={buttonText}
buttonKind={buttonKind}
buttonDisabled={Boolean(joinCallButtonDisabledTooltip)}
buttonTooltip={joinCallButtonDisabledTooltip}
onButtonClick={onButtonClick}
/>;
});
Expand Down
27 changes: 18 additions & 9 deletions src/components/views/voip/CallView.tsx
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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>;
};

Expand Down Expand Up @@ -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
Expand All @@ -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">
Expand Down
22 changes: 21 additions & 1 deletion src/hooks/useCall.ts
Expand Up @@ -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));
Expand All @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions src/i18n/strings/en_EN.json
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
40 changes: 31 additions & 9 deletions src/toasts/IncomingCallToast.tsx
Expand Up @@ -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";
Expand All @@ -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;
}
Expand Down Expand Up @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions test/components/views/voip/CallView-test.tsx
Expand Up @@ -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";
Expand All @@ -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);

Expand Down Expand Up @@ -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", () => {
Expand Down