Add Element Call participant limit (#9358)

This commit is contained in:
Šimon Brandner 2022-10-07 22:16:35 +02:00 committed by GitHub
parent 5680d13acf
commit bb71c86c8a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 107 additions and 26 deletions

View file

@ -119,6 +119,7 @@ export interface IConfigOptions {
element_call: { element_call: {
url?: string; url?: string;
use_exclusively?: boolean; use_exclusively?: boolean;
participant_limit?: number;
brand?: string; brand?: string;
}; };

View file

@ -33,6 +33,7 @@ export const DEFAULTS: IConfigOptions = {
element_call: { element_call: {
url: "https://call.element.io", url: "https://call.element.io",
use_exclusively: false, use_exclusively: false,
participant_limit: 8,
brand: "Element Call", brand: "Element Call",
}, },

View file

@ -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 type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Call, ConnectionState } from "../../../models/Call"; import { Call, ConnectionState } from "../../../models/Call";
import { _t } from "../../../languageHandler"; 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 defaultDispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
@ -28,9 +28,9 @@ import type { ButtonEvent } from "../elements/AccessibleButton";
import MemberAvatar from "../avatars/MemberAvatar"; import MemberAvatar from "../avatars/MemberAvatar";
import { LiveContentSummary, LiveContentType } from "../rooms/LiveContentSummary"; import { LiveContentSummary, LiveContentType } from "../rooms/LiveContentSummary";
import FacePile from "../elements/FacePile"; import FacePile from "../elements/FacePile";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { CallDuration, CallDurationFromEvent } from "../voip/CallDuration"; import { CallDuration, CallDurationFromEvent } from "../voip/CallDuration";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
const MAX_FACES = 8; const MAX_FACES = 8;
@ -39,6 +39,8 @@ interface ActiveCallEventProps {
participants: Set<RoomMember>; participants: Set<RoomMember>;
buttonText: string; buttonText: string;
buttonKind: string; buttonKind: string;
buttonTooltip?: string;
buttonDisabled?: boolean;
onButtonClick: ((ev: ButtonEvent) => void) | null; onButtonClick: ((ev: ButtonEvent) => void) | null;
} }
@ -49,6 +51,8 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
participants, participants,
buttonText, buttonText,
buttonKind, buttonKind,
buttonDisabled,
buttonTooltip,
onButtonClick, onButtonClick,
}, },
ref, ref,
@ -80,14 +84,15 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
<FacePile members={facePileMembers} faceSize={24} overflow={facePileOverflow} /> <FacePile members={facePileMembers} faceSize={24} overflow={facePileOverflow} />
</div> </div>
<CallDurationFromEvent mxEvent={mxEvent} /> <CallDurationFromEvent mxEvent={mxEvent} />
<AccessibleButton <AccessibleTooltipButton
className="mx_CallEvent_button" className="mx_CallEvent_button"
kind={buttonKind} kind={buttonKind}
disabled={onButtonClick === null} disabled={onButtonClick === null || buttonDisabled}
onClick={onButtonClick} onClick={onButtonClick}
tooltip={buttonTooltip}
> >
{ buttonText } { buttonText }
</AccessibleButton> </AccessibleTooltipButton>
</div> </div>
</div>; </div>;
}, },
@ -101,6 +106,7 @@ interface ActiveLoadedCallEventProps {
const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxEvent, call }, ref) => { const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxEvent, call }, ref) => {
const connectionState = useConnectionState(call); const connectionState = useConnectionState(call);
const participants = useParticipants(call); const participants = useParticipants(call);
const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
const connect = useCallback((ev: ButtonEvent) => { const connect = useCallback((ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
@ -132,6 +138,8 @@ const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxE
participants={participants} participants={participants}
buttonText={buttonText} buttonText={buttonText}
buttonKind={buttonKind} buttonKind={buttonKind}
buttonDisabled={Boolean(joinCallButtonDisabledTooltip)}
buttonTooltip={joinCallButtonDisabledTooltip}
onButtonClick={onButtonClick} onButtonClick={onButtonClick}
/>; />;
}); });

View file

@ -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 { Room } from "matrix-js-sdk/src/models/room";
import type { ConnectionState } from "../../../models/Call"; import type { ConnectionState } from "../../../models/Call";
import { Call, CallEvent, ElementCall, isConnected } 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 MatrixClientContext from "../../../contexts/MatrixClientContext";
import AppTile from "../elements/AppTile"; import AppTile from "../elements/AppTile";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
@ -35,7 +35,7 @@ import IconizedContextMenu, {
} from "../context_menus/IconizedContextMenu"; } from "../context_menus/IconizedContextMenu";
import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import { Alignment } from "../elements/Tooltip"; import { Alignment } from "../elements/Tooltip";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import { ButtonEvent } from "../elements/AccessibleButton";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import FacePile from "../elements/FacePile"; import FacePile from "../elements/FacePile";
import MemberAvatar from "../avatars/MemberAvatar"; import MemberAvatar from "../avatars/MemberAvatar";
@ -110,10 +110,11 @@ const MAX_FACES = 8;
interface LobbyProps { interface LobbyProps {
room: Room; room: Room;
connect: () => Promise<void>; connect: () => Promise<void>;
joinCallButtonDisabledTooltip?: string;
children?: ReactNode; 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 [connecting, setConnecting] = useState(false);
const me = useMemo(() => room.getMember(room.myUserId)!, [room]); const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
@ -233,14 +234,15 @@ export const Lobby: FC<LobbyProps> = ({ room, connect, children }) => {
/> />
</div> </div>
</div> </div>
<AccessibleButton <AccessibleTooltipButton
className="mx_CallView_connectButton" className="mx_CallView_connectButton"
kind="primary" kind="primary"
disabled={connecting} disabled={connecting || Boolean(joinCallButtonDisabledTooltip)}
onClick={onConnectClick} onClick={onConnectClick}
> title={_t("Join")}
{ _t("Join") } label={_t("Join")}
</AccessibleButton> tooltip={connecting ? _t("Connecting") : joinCallButtonDisabledTooltip}
/>
</div>; </div>;
}; };
@ -321,6 +323,7 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const connected = isConnected(useConnectionState(call)); const connected = isConnected(useConnectionState(call));
const participants = useParticipants(call); const participants = useParticipants(call);
const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
const connect = useCallback(async () => { const connect = useCallback(async () => {
// Disconnect from any other active calls first, since we don't yet support holding // 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>; </div>;
} }
lobby = <Lobby room={room} connect={connect}>{ facePile }</Lobby>; lobby = <Lobby
room={room}
connect={connect}
joinCallButtonDisabledTooltip={joinCallButtonDisabledTooltip}
>
{ facePile }
</Lobby>;
} }
return <div className="mx_CallView"> return <div className="mx_CallView">

View file

@ -17,11 +17,13 @@ limitations under the License.
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; 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 { useTypedEventEmitterState } from "./useEventEmitter";
import { CallEvent } from "../models/Call"; import { CallEvent } from "../models/Call";
import { CallStore, CallStoreEvent } from "../stores/CallStore"; import { CallStore, CallStoreEvent } from "../stores/CallStore";
import { useEventEmitter } from "./useEventEmitter"; import { useEventEmitter } from "./useEventEmitter";
import SdkConfig, { DEFAULTS } from "../SdkConfig";
import { _t } from "../languageHandler";
export const useCall = (roomId: string): Call | null => { export const useCall = (roomId: string): Call | null => {
const [call, setCall] = useState(() => CallStore.instance.getCall(roomId)); 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]), 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 => export const useLayout = (call: ElementCall): Layout =>
useTypedEventEmitterState( useTypedEventEmitterState(
call, call,

View file

@ -797,10 +797,10 @@
"Don't miss a reply": "Don't miss a reply", "Don't miss a reply": "Don't miss a reply",
"Notifications": "Notifications", "Notifications": "Notifications",
"Enable desktop notifications": "Enable desktop notifications", "Enable desktop notifications": "Enable desktop notifications",
"Join": "Join",
"Unknown room": "Unknown room", "Unknown room": "Unknown room",
"Video call started": "Video call started", "Video call started": "Video call started",
"Video": "Video", "Video": "Video",
"Join": "Join",
"Close": "Close", "Close": "Close",
"Unknown caller": "Unknown caller", "Unknown caller": "Unknown caller",
"Voice call": "Voice call", "Voice call": "Voice call",
@ -1014,6 +1014,8 @@
"When rooms are upgraded": "When rooms are upgraded", "When rooms are upgraded": "When rooms are upgraded",
"My Ban List": "My Ban List", "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!", "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", "Create account": "Create account",
"You made it!": "You made it!", "You made it!": "You made it!",
"Find and invite your friends": "Find and invite your friends", "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>Switch</a>": "You held the call <a>Switch</a>",
"You held the call <a>Resume</a>": "You held the call <a>Resume</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", "%(peerName)s held the call": "%(peerName)s held the call",
"Connecting": "Connecting",
"Dialpad": "Dialpad", "Dialpad": "Dialpad",
"Mute the microphone": "Mute the microphone", "Mute the microphone": "Mute the microphone",
"Unmute the microphone": "Unmute the microphone", "Unmute the microphone": "Unmute the microphone",

View file

@ -19,7 +19,6 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import RoomAvatar from '../components/views/avatars/RoomAvatar'; import RoomAvatar from '../components/views/avatars/RoomAvatar';
import AccessibleButton from '../components/views/elements/AccessibleButton';
import { MatrixClientPeg } from "../MatrixClientPeg"; import { MatrixClientPeg } from "../MatrixClientPeg";
import defaultDispatcher from "../dispatcher/dispatcher"; import defaultDispatcher from "../dispatcher/dispatcher";
import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
@ -31,14 +30,34 @@ import {
LiveContentSummaryWithCall, LiveContentSummaryWithCall,
LiveContentType, LiveContentType,
} from "../components/views/rooms/LiveContentSummary"; } from "../components/views/rooms/LiveContentSummary";
import { useCall } from "../hooks/useCall"; import { useCall, useJoinCallButtonDisabledTooltip } from "../hooks/useCall";
import { useRoomState } from "../hooks/useRoomState"; import { useRoomState } from "../hooks/useRoomState";
import { ButtonEvent } from "../components/views/elements/AccessibleButton"; import { ButtonEvent } from "../components/views/elements/AccessibleButton";
import { useDispatcher } from "../hooks/useDispatcher"; import { useDispatcher } from "../hooks/useDispatcher";
import { ActionPayload } from "../dispatcher/payloads"; import { ActionPayload } from "../dispatcher/payloads";
import { Call } from "../models/Call";
export const getIncomingCallToastKey = (stateKey: string) => `call_${stateKey}`; 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 { interface Props {
callEvent: MatrixEvent; callEvent: MatrixEvent;
} }
@ -114,13 +133,16 @@ export function IncomingCallToast({ callEvent }: Props) {
/> />
} }
</div> </div>
<AccessibleButton { call
? <JoinCallButtonWithCall onClick={onJoinClick} call={call} />
: <AccessibleTooltipButton
className="mx_IncomingCallToast_joinButton" className="mx_IncomingCallToast_joinButton"
onClick={onJoinClick} onClick={onJoinClick}
kind="primary" kind="primary"
> >
{ _t("Join") } { _t("Join") }
</AccessibleButton> </AccessibleTooltipButton>
}
</div> </div>
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_IncomingCallToast_closeButton" className="mx_IncomingCallToast_closeButton"

View file

@ -22,6 +22,7 @@ import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { Widget } from "matrix-widget-api"; import { Widget } from "matrix-widget-api";
import "@testing-library/jest-dom";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { ClientWidgetApi } from "matrix-widget-api"; 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 { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import { CallStore } from "../../../../src/stores/CallStore"; import { CallStore } from "../../../../src/stores/CallStore";
import { Call, ConnectionState } from "../../../../src/models/Call"; import { Call, ConnectionState } from "../../../../src/models/Call";
import SdkConfig from "../../../../src/SdkConfig";
const CallView = wrapInMatrixClientContext(_CallView); const CallView = wrapInMatrixClientContext(_CallView);
@ -163,6 +165,23 @@ describe("CallLobby", () => {
fireEvent.click(screen.getByRole("button", { name: "Join" })); fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() => expect(connectSpy).toHaveBeenCalled(), { interval: 1 }); 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", () => { describe("without an existing call", () => {