From b5accb6acad17841dcd594220ecfdb794c81c9e4 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 22 Sep 2022 20:51:02 -0400 Subject: [PATCH 01/10] Make useEventEmitterState more efficient By not invoking the initializing function on every render --- src/hooks/useEventEmitter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useEventEmitter.ts b/src/hooks/useEventEmitter.ts index 46a7d8f184c..ff1592028ae 100644 --- a/src/hooks/useEventEmitter.ts +++ b/src/hooks/useEventEmitter.ts @@ -87,7 +87,7 @@ export function useEventEmitterState( eventName: string | symbol, fn: Mapper, ): T { - const [value, setValue] = useState(fn()); + const [value, setValue] = useState(fn); const handler = useCallback((...args: any[]) => { setValue(fn(...args)); }, [fn]); From 37b777f5b8b5fc5daf57eda13e59a2dc00ca1d1b Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 22 Sep 2022 20:51:51 -0400 Subject: [PATCH 02/10] Make useWidgets more efficient By not calling WidgetStore on every render --- src/components/views/right_panel/RoomSummaryCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index d2429f1a7be..05316ffff65 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -76,7 +76,7 @@ const Button: React.FC = ({ children, className, onClick }) => { }; export const useWidgets = (room: Room) => { - const [apps, setApps] = useState(WidgetStore.instance.getApps(room.roomId)); + const [apps, setApps] = useState(() => WidgetStore.instance.getApps(room.roomId)); const updateApps = useCallback(() => { // Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings From 1656394b591cc01f3bcf8600bdbf3d74b9979cf9 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 22 Sep 2022 20:52:55 -0400 Subject: [PATCH 03/10] Add new group call experience Labs flag --- src/i18n/strings/en_EN.json | 3 ++- src/settings/Settings.tsx | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1534c4d51f1..62a5d54be3b 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -865,6 +865,7 @@ "Spaces": "Spaces", "Widgets": "Widgets", "Rooms": "Rooms", + "Voice & Video": "Voice & Video", "Moderation": "Moderation", "Analytics": "Analytics", "Message Previews": "Message Previews", @@ -910,6 +911,7 @@ "Send read receipts": "Send read receipts", "Sliding Sync mode (under active development, cannot be disabled)": "Sliding Sync mode (under active development, cannot be disabled)", "Element Call video rooms": "Element Call video rooms", + "New group call experience": "New group call experience", "Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)", "Favourite Messages (under active development)": "Favourite Messages (under active development)", "Voice broadcast (under active development)": "Voice broadcast (under active development)", @@ -1591,7 +1593,6 @@ "No Microphones detected": "No Microphones detected", "Camera": "Camera", "No Webcams detected": "No Webcams detected", - "Voice & Video": "Voice & Video", "This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers", "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.", "Upgrade this space to the recommended room version": "Upgrade this space to the recommended room version", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 0567d10fb89..5220f9d0604 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -92,6 +92,7 @@ export enum LabGroup { Spaces, Widgets, Rooms, + VoiceAndVideo, Moderation, Analytics, MessagePreviews, @@ -111,6 +112,7 @@ export const labGroupNames: Record = { [LabGroup.Spaces]: _td("Spaces"), [LabGroup.Widgets]: _td("Widgets"), [LabGroup.Rooms]: _td("Rooms"), + [LabGroup.VoiceAndVideo]: _td("Voice & Video"), [LabGroup.Moderation]: _td("Moderation"), [LabGroup.Analytics]: _td("Analytics"), [LabGroup.MessagePreviews]: _td("Message Previews"), @@ -191,7 +193,7 @@ export type ISetting = IBaseSetting | IFeature; export const SETTINGS: {[setting: string]: ISetting} = { "feature_video_rooms": { isFeature: true, - labsGroup: LabGroup.Rooms, + labsGroup: LabGroup.VoiceAndVideo, displayName: _td("Video rooms"), supportedLevels: LEVELS_FEATURE, default: false, @@ -426,11 +428,18 @@ export const SETTINGS: {[setting: string]: ISetting} = { "feature_element_call_video_rooms": { isFeature: true, supportedLevels: LEVELS_FEATURE, - labsGroup: LabGroup.Rooms, + labsGroup: LabGroup.VoiceAndVideo, displayName: _td("Element Call video rooms"), controller: new ReloadOnChangeController(), default: false, }, + "feature_group_calls": { + isFeature: true, + supportedLevels: LEVELS_FEATURE, + labsGroup: LabGroup.VoiceAndVideo, + displayName: _td("New group call experience"), + default: false, + }, "feature_location_share_live": { isFeature: true, labsGroup: LabGroup.Messaging, From 62e4b2705947e68872090044569854cd5c986d4a Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 22 Sep 2022 20:56:07 -0400 Subject: [PATCH 04/10] Add viewingCall field to RoomViewStore state Currently has no effect, but in the future this will signal to RoomView to show the call or call lobby. --- src/dispatcher/payloads/ViewRoomPayload.ts | 1 + src/stores/RoomViewStore.tsx | 118 +++++++++++++++------ 2 files changed, 84 insertions(+), 35 deletions(-) diff --git a/src/dispatcher/payloads/ViewRoomPayload.ts b/src/dispatcher/payloads/ViewRoomPayload.ts index cd62f7ca3fd..e497939ff04 100644 --- a/src/dispatcher/payloads/ViewRoomPayload.ts +++ b/src/dispatcher/payloads/ViewRoomPayload.ts @@ -47,6 +47,7 @@ export interface ViewRoomPayload extends Pick { forceTimeline?: boolean; // Whether to override default behaviour to end up at a timeline show_room_tile?: boolean; // Whether to ensure that the room tile is visible in the room list clear_search?: boolean; // Whether to clear the room list search + view_call?: boolean; // Whether to view the call or call lobby for the room deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 3c127275a25..3db9c084342 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -53,38 +53,75 @@ import { UPDATE_EVENT } from "./AsyncStore"; const NUM_JOIN_RETRY = 5; -const INITIAL_STATE = { - // Whether we're joining the currently viewed room (see isJoining()) - joining: false, - // Any error that has occurred during joining - joinError: null as Error, - // The room ID of the room currently being viewed - roomId: null as string, - // The room ID being subscribed to (in Sliding Sync) - subscribingRoomId: null as string, +interface State { + /** + * Whether we're joining the currently viewed (see isJoining()) + */ + joining: boolean; + /** + * Any error that has occurred during joining + */ + joinError: Error | null; + /** + * The ID of the room currently being viewed + */ + roomId: string | null; + /** + * The ID of the room being subscribed to (in Sliding Sync) + */ + subscribingRoomId: string | null; + /** + * The event to scroll to when the room is first viewed + */ + initialEventId: string | null; + initialEventPixelOffset: number | null; + /** + * Whether to highlight the initial event + */ + isInitialEventHighlighted: boolean; + /** + * Whether to scroll the initial event into view + */ + initialEventScrollIntoView: boolean; + /** + * The alias of the room (or null if not originally specified in view_room) + */ + roomAlias: string | null; + /** + * Whether the current room is loading + */ + roomLoading: boolean; + /** + * Any error that has occurred during loading + */ + roomLoadError: MatrixError | null; + replyingToEvent: MatrixEvent | null; + shouldPeek: boolean; + viaServers: string[]; + wasContextSwitch: boolean; + /** + * Whether we're viewing a call or call lobby in this room + */ + viewingCall: boolean; +} - // The event to scroll to when the room is first viewed - initialEventId: null as string, - initialEventPixelOffset: null as number, - // Whether to highlight the initial event +const INITIAL_STATE: State = { + joining: false, + joinError: null, + roomId: null, + subscribingRoomId: null, + initialEventId: null, + initialEventPixelOffset: null, isInitialEventHighlighted: false, - // whether to scroll `event_id` into view initialEventScrollIntoView: true, - - // The room alias of the room (or null if not originally specified in view_room) - roomAlias: null as string, - // Whether the current room is loading + roomAlias: null, roomLoading: false, - // Any error that has occurred during loading - roomLoadError: null as MatrixError, - - replyingToEvent: null as MatrixEvent, - + roomLoadError: null, + replyingToEvent: null, shouldPeek: false, - - viaServers: [] as string[], - + viaServers: [], wasContextSwitch: false, + viewingCall: false, }; type Listener = (isActive: boolean) => void; @@ -98,7 +135,7 @@ export class RoomViewStore extends EventEmitter { // the app. We need to eagerly create the instance. public static readonly instance = new RoomViewStore(defaultDispatcher); - private state = INITIAL_STATE; // initialize state + private state: State = INITIAL_STATE; // initialize state private dis: MatrixDispatcher; private dispatchToken: string; @@ -120,7 +157,7 @@ export class RoomViewStore extends EventEmitter { this.emit(roomId, isActive); } - private setState(newState: Partial): void { + private setState(newState: Partial): void { // If values haven't changed, there's nothing to do. // This only tries a shallow comparison, so unchanged objects will slip // through, but that's probably okay for now. @@ -172,6 +209,7 @@ export class RoomViewStore extends EventEmitter { roomAlias: null, viaServers: [], wasContextSwitch: false, + viewingCall: false, }); break; case Action.ViewRoomError: @@ -286,6 +324,7 @@ export class RoomViewStore extends EventEmitter { roomLoadError: null, viaServers: payload.via_servers, wasContextSwitch: payload.context_switch, + viewingCall: payload.view_call ?? false, }); // set this room as the room subscription. We need to await for it as this will fetch // all room state for this room, which is required before we get the state below. @@ -303,11 +342,11 @@ export class RoomViewStore extends EventEmitter { return; } - const newState = { + const newState: Partial = { roomId: payload.room_id, - roomAlias: payload.room_alias, - initialEventId: payload.event_id, - isInitialEventHighlighted: payload.highlighted, + roomAlias: payload.room_alias ?? null, + initialEventId: payload.event_id ?? null, + isInitialEventHighlighted: payload.highlighted ?? false, initialEventScrollIntoView: payload.scroll_into_view ?? true, roomLoading: false, roomLoadError: null, @@ -317,8 +356,12 @@ export class RoomViewStore extends EventEmitter { joining: payload.joining || false, // Reset replyingToEvent because we don't want cross-room because bad UX replyingToEvent: null, - viaServers: payload.via_servers, - wasContextSwitch: payload.context_switch, + viaServers: payload.via_servers ?? [], + wasContextSwitch: payload.context_switch ?? false, + viewingCall: payload.view_call ?? ( + // Reset to false when switching rooms + payload.room_id === this.state.roomId ? this.state.viewingCall : false + ), }; // Allow being given an event to be replied to when switching rooms but sanity check its for this room @@ -351,13 +394,14 @@ export class RoomViewStore extends EventEmitter { roomId: null, initialEventId: null, initialEventPixelOffset: null, - isInitialEventHighlighted: null, + isInitialEventHighlighted: false, initialEventScrollIntoView: true, roomAlias: payload.room_alias, roomLoading: true, roomLoadError: null, viaServers: payload.via_servers, wasContextSwitch: payload.context_switch, + viewingCall: payload.view_call ?? false, }); try { const result = await MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias); @@ -577,4 +621,8 @@ export class RoomViewStore extends EventEmitter { public getWasContextSwitch(): boolean { return this.state.wasContextSwitch; } + + public isViewingCall(): boolean { + return this.state.viewingCall; + } } From f643fa490c5be40c7f5137ea35150cc1fe907fb2 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 22 Sep 2022 20:58:07 -0400 Subject: [PATCH 05/10] Add element_call.use_exclusively config flag As documented in element-web, this will tell the app to use Element Call exclusively for calls, disabling Jitsi and legacy 1:1 calls. --- src/IConfigOptions.ts | 1 + src/SdkConfig.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index 837d3050f35..b877cb90af5 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -118,6 +118,7 @@ export interface IConfigOptions { }; element_call: { url: string; + use_exclusively: boolean; }; logout_redirect_url?: string; diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index d466a050741..7a869827235 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -32,6 +32,7 @@ export const DEFAULTS: IConfigOptions = { }, element_call: { url: "https://call.element.io", + use_exclusively: false, }, // @ts-ignore - we deliberately use the camelCase version here so we trigger From 450a24b80ce62522f1c48832371695d71a47b795 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 22 Sep 2022 20:54:13 -0400 Subject: [PATCH 06/10] Make placeCall return a promise So that the UI can know when placeCall completes --- src/LegacyCallHandler.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index 624dc86a33f..a924388eadb 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -820,10 +820,10 @@ export default class LegacyCallHandler extends EventEmitter { } } - public placeCall(roomId: string, type?: CallType, transferee?: MatrixCall): void { + public async placeCall(roomId: string, type?: CallType, transferee?: MatrixCall): Promise { // We might be using managed hybrid widgets if (isManagedHybridWidgetEnabled()) { - addManagedHybridWidget(roomId); + await addManagedHybridWidget(roomId); return; } @@ -870,9 +870,9 @@ export default class LegacyCallHandler extends EventEmitter { } else if (members.length === 2) { logger.info(`Place ${type} call in ${roomId}`); - this.placeMatrixCall(roomId, type, transferee); + await this.placeMatrixCall(roomId, type, transferee); } else { // > 2 - this.placeJitsiCall(roomId, type); + await this.placeJitsiCall(roomId, type); } } From 9ba017933a6cfbbcaf4c637e1bb74a95be4b12ac Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 22 Sep 2022 21:05:14 -0400 Subject: [PATCH 07/10] Update start call buttons to new group call designs Since RoomView doesn't do anything with viewingCall yet, these buttons won't have any effect when starting native group calls, but the logic is at least all there and ready to be hooked up. --- src/components/structures/RoomView.tsx | 21 +- src/components/views/rooms/RoomHeader.tsx | 320 ++++++++++++++++-- src/i18n/strings/en_EN.json | 6 + .../views/rooms/RoomHeader-test.tsx | 2 +- 4 files changed, 299 insertions(+), 50 deletions(-) diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index fed78d76177..1c20195b685 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -30,7 +30,7 @@ import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; import { EventType } from 'matrix-js-sdk/src/@types/event'; import { RoomState, RoomStateEvent } from 'matrix-js-sdk/src/models/room-state'; import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; -import { CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { throttle } from "lodash"; import { MatrixError } from 'matrix-js-sdk/src/http-api'; import { ClientEvent } from "matrix-js-sdk/src/client"; @@ -149,7 +149,7 @@ interface IRoomProps extends MatrixClientProps { enum MainSplitContentType { Timeline, MaximisedWidget, - Video, // immersive voip + Call, } export interface IRoomState { room?: Room; @@ -299,7 +299,6 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { e2eStatus={E2EStatus.Normal} onAppsClick={null} appsShown={false} - onCallPlaced={null} excludedRightPanelPhaseButtons={[]} showButtons={false} enableRoomOptionsMenu={false} @@ -350,7 +349,6 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement e2eStatus={E2EStatus.Normal} onAppsClick={null} appsShown={false} - onCallPlaced={null} excludedRightPanelPhaseButtons={[]} showButtons={false} enableRoomOptionsMenu={false} @@ -517,7 +515,7 @@ export class RoomView extends React.Component { private getMainSplitContentType = (room: Room) => { if (SettingsStore.getValue("feature_video_rooms") && isVideoRoom(room)) { - return MainSplitContentType.Video; + return MainSplitContentType.Call; } if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { return MainSplitContentType.MaximisedWidget; @@ -1660,10 +1658,6 @@ export class RoomView extends React.Component { return ret; } - private onCallPlaced = (type: CallType): void => { - LegacyCallHandler.instance.placeCall(this.state.room?.roomId, type); - }; - private onAppsClick = () => { dis.dispatch({ action: "appsDrawer", @@ -2330,7 +2324,7 @@ export class RoomView extends React.Component { const mainClasses = classNames("mx_RoomView", { mx_RoomView_inCall: Boolean(activeCall), - mx_RoomView_immersive: this.state.mainSplitContentType === MainSplitContentType.Video, + mx_RoomView_immersive: this.state.mainSplitContentType === MainSplitContentType.Call, }); const showChatEffects = SettingsStore.getValue('showChatEffects'); @@ -2371,7 +2365,7 @@ export class RoomView extends React.Component { { previewBar } ; break; - case MainSplitContentType.Video: { + case MainSplitContentType.Call: { mainSplitContentClassName = "mx_MainSplit_video"; mainSplitBody = <> @@ -2382,7 +2376,6 @@ export class RoomView extends React.Component { const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName); let excludedRightPanelPhaseButtons = [RightPanelPhases.Timeline]; - let onCallPlaced = this.onCallPlaced; let onAppsClick = this.onAppsClick; let onForgetClick = this.onForgetClick; let onSearchClick = this.onSearchClick; @@ -2399,13 +2392,12 @@ export class RoomView extends React.Component { onForgetClick = null; onSearchClick = null; break; - case MainSplitContentType.Video: + case MainSplitContentType.Call: excludedRightPanelPhaseButtons = [ RightPanelPhases.ThreadPanel, RightPanelPhases.PinnedMessages, RightPanelPhases.NotificationPanel, ]; - onCallPlaced = null; onAppsClick = null; onForgetClick = null; onSearchClick = null; @@ -2432,7 +2424,6 @@ export class RoomView extends React.Component { e2eStatus={this.state.e2eStatus} onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null} appsShown={this.state.showApps} - onCallPlaced={onCallPlaced} excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons} showButtons={!this.viewsLocalRoom} enableRoomOptionsMenu={!this.viewsLocalRoom} diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index d64d3d7e322..860d74e56e4 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -15,12 +15,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { FC, useState, useMemo, useCallback } from 'react'; import classNames from 'classnames'; import { throttle } from 'lodash'; -import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk/src/matrix'; +import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; +import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import type { Room } from "matrix-js-sdk/src/models/room"; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import defaultDispatcher from "../../../dispatcher/dispatcher"; @@ -30,13 +32,14 @@ import SettingsStore from "../../../settings/SettingsStore"; import RoomHeaderButtons from '../right_panel/RoomHeaderButtons'; import E2EIcon from './E2EIcon'; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; +import { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import RoomTopic from "../elements/RoomTopic"; import RoomName from "../elements/RoomName"; import { E2EStatus } from '../../../utils/ShieldUtils'; import { IOOBData } from '../../../stores/ThreepidInviteStore'; import { SearchScope } from './SearchBar'; -import { ContextMenuTooltipButton } from '../../structures/ContextMenu'; +import { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu'; import RoomContextMenu from "../context_menus/RoomContextMenu"; import { contextMenuBelow } from './RoomTile'; import { RoomNotificationStateStore } from '../../../stores/notifications/RoomNotificationStateStore'; @@ -48,6 +51,272 @@ import { BetaPill } from "../beta/BetaCard"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; +import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler"; +import { useFeatureEnabled, useSettingValue } from "../../../hooks/useSettings"; +import SdkConfig from "../../../SdkConfig"; +import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; +import { useWidgets } from "../right_panel/RoomSummaryCard"; +import { WidgetType } from "../../../widgets/WidgetType"; +import { useCall } from "../../../hooks/useCall"; +import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers"; +import { ElementCall } from "../../../models/Call"; +import IconizedContextMenu, { + IconizedContextMenuOption, + IconizedContextMenuOptionList, +} from "../context_menus/IconizedContextMenu"; +import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; + +class DisabledWithReason { + constructor(public readonly reason: string) { } +} + +interface VoiceCallButtonProps { + room: Room; + busy: boolean; + setBusy: (value: boolean) => void; + behavior: DisabledWithReason | "legacy"; +} + +/** + * Button for starting voice calls, supporting only legacy 1:1 calls and Jitsi + * widgets. + */ +const VoiceCallButton: FC = ({ room, busy, setBusy, behavior }) => { + const { onClick, tooltip, disabled } = useMemo(() => { + if (behavior instanceof DisabledWithReason) { + return { + onClick: () => {}, + tooltip: behavior.reason, + disabled: true, + }; + } else { + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + setBusy(true); + await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Voice); + setBusy(false); + }, + disabled: false, + }; + } + }, [behavior, room, setBusy]); + + return ; +}; + +interface VideoCallButtonProps { + room: Room; + busy: boolean; + setBusy: (value: boolean) => void; + behavior: DisabledWithReason | "legacy" | "element" | "legacy_or_element"; +} + +/** + * Button for starting video calls, supporting both legacy 1:1 calls, Jitsi + * widgets, and native group calls. If multiple calling options are available, + * this shows a menu to pick between them. + */ +const VideoCallButton: FC = ({ room, busy, setBusy, behavior }) => { + const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu(); + + const startLegacyCall = useCallback(async () => { + setBusy(true); + await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Video); + setBusy(false); + }, [setBusy, room]); + + const startElementCall = useCallback(() => { + setBusy(true); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + metricsTrigger: undefined, + }); + setBusy(false); + }, [setBusy, room]); + + const { onClick, tooltip, disabled } = useMemo(() => { + if (behavior instanceof DisabledWithReason) { + return { + onClick: () => {}, + tooltip: behavior.reason, + disabled: true, + }; + } else if (behavior === "legacy") { + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + await startLegacyCall(); + }, + disabled: false, + }; + } else if (behavior === "element") { + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + startElementCall(); + }, + disabled: false, + }; + } else { + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + openMenu(); + }, + disabled: false, + }; + } + }, [behavior, startLegacyCall, startElementCall, openMenu]); + + const onJitsiClick = useCallback(async (ev: ButtonEvent) => { + ev.preventDefault(); + closeMenu(); + await startLegacyCall(); + }, [closeMenu, startLegacyCall]); + + const onElementClick = useCallback((ev: ButtonEvent) => { + ev.preventDefault(); + closeMenu(); + startElementCall(); + }, [closeMenu, startElementCall]); + + let menu: JSX.Element | null = null; + if (menuOpen) { + const buttonRect = buttonRef.current!.getBoundingClientRect(); + menu = + + + + + ; + } + + return <> + + { menu } + ; +}; + +interface CallButtonsProps { + room: Room; +} + +// The header buttons for placing calls have become stupidly complex, so here +// they are as a separate component +const CallButtons: FC = ({ room }) => { + const [busy, setBusy] = useState(false); + const showButtons = useSettingValue("showCallButtonsInComposer"); + const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); + const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms"); + const isVideoRoom = useMemo(() => videoRoomsEnabled && calcIsVideoRoom(room), [videoRoomsEnabled, room]); + const useElementCallExclusively = useMemo(() => SdkConfig.get("element_call").use_exclusively, []); + + const hasLegacyCall = useEventEmitterState( + LegacyCallHandler.instance, + LegacyCallHandlerEvent.CallsChanged, + useCallback(() => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null, [room]), + ); + + const widgets = useWidgets(room); + const hasJitsiWidget = useMemo(() => widgets.some(widget => WidgetType.JITSI.matches(widget.type)), [widgets]); + + const hasElementCall = useCall(room.roomId) !== null; + + const [functionalMembers, mayEditWidgets, mayCreateElementCalls] = useTypedEventEmitterState( + room, + RoomStateEvent.Update, + useCallback(() => [ + getJoinedNonFunctionalMembers(room), + room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client), + room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client), + ], [room]), + ); + + const makeVoiceCallButton = (behavior: VoiceCallButtonProps["behavior"]): JSX.Element => + ; + const makeVideoCallButton = (behavior: VideoCallButtonProps["behavior"]): JSX.Element => + ; + + if (isVideoRoom || !showButtons) { + return null; + } else if (groupCallsEnabled) { + if (useElementCallExclusively) { + if (hasElementCall) { + return makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))); + } else if (mayCreateElementCalls) { + return makeVideoCallButton("element"); + } else { + return makeVideoCallButton( + new DisabledWithReason(_t("You do not have permission to start video calls")), + ); + } + } else if (hasLegacyCall || hasJitsiWidget || hasElementCall) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("Ongoing call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))) } + ; + } else if (functionalMembers.length <= 1) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + ; + } else if (functionalMembers.length === 2) { + return <> + { makeVoiceCallButton("legacy") } + { makeVideoCallButton("legacy") } + ; + } else if (mayEditWidgets) { + return <> + { makeVoiceCallButton("legacy") } + { makeVideoCallButton(mayCreateElementCalls ? "legacy_or_element" : "legacy") } + ; + } else { + const videoCallBehavior = mayCreateElementCalls + ? "element" + : new DisabledWithReason(_t("You do not have permission to start video calls")); + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("You do not have permission to start voice calls"))) } + { makeVideoCallButton(videoCallBehavior) } + ; + } + } else if (hasLegacyCall || hasJitsiWidget) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("Ongoing call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))) } + ; + } else if (functionalMembers.length <= 1) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + ; + } else if (functionalMembers.length === 2 || mayEditWidgets) { + return <> + { makeVoiceCallButton("legacy") } + { makeVideoCallButton("legacy") } + ; + } else { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("You do not have permission to start voice calls"))) } + { makeVideoCallButton(new DisabledWithReason(_t("You do not have permission to start video calls"))) } + ; + } +}; export interface ISearchInfo { searchTerm: string; @@ -55,15 +324,14 @@ export interface ISearchInfo { searchCount: number; } -interface IProps { +export interface IProps { room: Room; oobData?: IOOBData; inRoom: boolean; - onSearchClick: () => void; - onInviteClick: () => void; - onForgetClick: () => void; - onCallPlaced: (type: CallType) => void; - onAppsClick: () => void; + onSearchClick: (() => void) | null; + onInviteClick: (() => void) | null; + onForgetClick: (() => void) | null; + onAppsClick: (() => void) | null; e2eStatus: E2EStatus; appsShown: boolean; searchInfo: ISearchInfo; @@ -89,7 +357,7 @@ export default class RoomHeader extends React.Component { static contextType = RoomContext; public context!: React.ContextType; - constructor(props, context) { + constructor(props: IProps, context: IState) { super(props, context); const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room); notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate); @@ -141,30 +409,14 @@ export default class RoomHeader extends React.Component { }; private onContextMenuCloseClick = () => { - this.setState({ contextMenuPosition: null }); + this.setState({ contextMenuPosition: undefined }); }; private renderButtons(): JSX.Element[] { const buttons: JSX.Element[] = []; - if (this.props.inRoom && - this.props.onCallPlaced && - !this.context.tombstone && - SettingsStore.getValue("showCallButtonsInComposer") - ) { - const voiceCallButton = this.props.onCallPlaced(CallType.Voice)} - title={_t("Voice call")} - key="voice" - />; - const videoCallButton = this.props.onCallPlaced(CallType.Video)} - title={_t("Video call")} - key="video" - />; - buttons.push(voiceCallButton, videoCallButton); + if (this.props.inRoom && !this.context.tombstone) { + buttons.push(); } if (this.props.onForgetClick) { @@ -212,8 +464,8 @@ export default class RoomHeader extends React.Component { return buttons; } - private renderName(oobName) { - let contextMenu: JSX.Element; + private renderName(oobName: string) { + let contextMenu: JSX.Element | null = null; if (this.state.contextMenuPosition && this.props.room) { contextMenu = ( { } public render() { - let searchStatus = null; + let searchStatus: JSX.Element | null = null; // don't display the search count until the search completes and // gives us a valid (possibly zero) searchCount. @@ -291,7 +543,7 @@ export default class RoomHeader extends React.Component { className="mx_RoomHeader_topic" />; - let roomAvatar; + let roomAvatar: JSX.Element | null = null; if (this.props.room) { roomAvatar = { />; } - let buttons; + let buttons: JSX.Element | null = null; if (this.props.showButtons) { buttons =
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 62a5d54be3b..5c1359d499d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1869,6 +1869,12 @@ "Room %(name)s": "Room %(name)s", "Recently visited rooms": "Recently visited rooms", "No recently visited rooms": "No recently visited rooms", + "Video call (Jitsi)": "Video call (Jitsi)", + "Video call (Element Call)": "Video call (Element Call)", + "Ongoing call": "Ongoing call", + "You do not have permission to start video calls": "You do not have permission to start video calls", + "There's no one here to call": "There's no one here to call", + "You do not have permission to start voice calls": "You do not have permission to start voice calls", "Forget room": "Forget room", "Hide Widgets": "Hide Widgets", "Show Widgets": "Show Widgets", diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index baaf85eb446..cd8c9e255f9 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -119,7 +119,7 @@ describe('RoomHeader', () => { }); it("renders call buttons normally", () => { - const room = createRoom({ name: "Room", isDm: false, userIds: [] }); + const room = createRoom({ name: "Room", isDm: false, userIds: ["other"] }); const wrapper = render(room); expect(wrapper.find('[aria-label="Voice call"]').hostNodes()).toHaveLength(1); From 3e3317db90754b0931a58d6654533cafa34f6733 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 23 Sep 2022 18:22:52 -0400 Subject: [PATCH 08/10] Allow calls to be detected if the new group call experience is enabled --- src/models/Call.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 9b11261e85d..bc8bb6a65a2 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -635,11 +635,13 @@ export class ElementCall extends Call { } public static get(room: Room): ElementCall | null { - // Only supported in video rooms (for now) + // Only supported in the new group call experience or in video rooms if ( - SettingsStore.getValue("feature_video_rooms") - && SettingsStore.getValue("feature_element_call_video_rooms") - && room.isCallRoom() + SettingsStore.getValue("feature_group_calls") || ( + SettingsStore.getValue("feature_video_rooms") + && SettingsStore.getValue("feature_element_call_video_rooms") + && room.isCallRoom() + ) ) { const groupCalls = ElementCall.CALL_EVENT_TYPE.names.flatMap(eventType => room.currentState.getStateEvents(eventType), From dd1de515fa95642a5ebdd4d3cfe965875d73b472 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 23 Sep 2022 18:23:37 -0400 Subject: [PATCH 09/10] Test the RoomHeader changes --- package.json | 1 + src/stores/WidgetEchoStore.ts | 2 +- .../views/rooms/RoomHeader-test.tsx | 466 +++++++++++++++++- yarn.lock | 155 +++++- 4 files changed, 597 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 389478941f9..15dc5b6f791 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "@percy/cypress": "^3.1.1", "@sentry/types": "^6.10.0", "@sinonjs/fake-timers": "^9.1.2", + "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", "@types/classnames": "^2.2.11", "@types/commonmark": "^0.27.4", diff --git a/src/stores/WidgetEchoStore.ts b/src/stores/WidgetEchoStore.ts index 2923d46b09e..ac524eb4cf6 100644 --- a/src/stores/WidgetEchoStore.ts +++ b/src/stores/WidgetEchoStore.ts @@ -111,7 +111,7 @@ class WidgetEchoStore extends EventEmitter { } } -let singletonWidgetEchoStore = null; +let singletonWidgetEchoStore: WidgetEchoStore | null = null; if (!singletonWidgetEchoStore) { singletonWidgetEchoStore = new WidgetEchoStore(); } diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index cd8c9e255f9..f1f9002d1fe 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -17,23 +17,48 @@ limitations under the License. import React from 'react'; // eslint-disable-next-line deprecate/import import { mount, ReactWrapper } from 'enzyme'; -import { Room, PendingEventOrdering, MatrixEvent, MatrixClient } from 'matrix-js-sdk/src/matrix'; - -import * as TestUtils from '../../../test-utils'; +import { render, screen, act, fireEvent, waitFor, getByRole } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { mocked, Mocked } from "jest-mock"; +import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; +import { PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; + +import type { MatrixClient } from "matrix-js-sdk/src/client"; +import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import type { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import { + stubClient, + mkRoomMember, + setupAsyncStoreWithClient, + resetAsyncStoreWithClient, + mockPlatformPeg, +} from "../../../test-utils"; import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; import DMRoomMap from '../../../../src/utils/DMRoomMap'; -import RoomHeader from '../../../../src/components/views/rooms/RoomHeader'; +import RoomHeader, { IProps as RoomHeaderProps } from "../../../../src/components/views/rooms/RoomHeader"; import { SearchScope } from '../../../../src/components/views/rooms/SearchBar'; import { E2EStatus } from '../../../../src/utils/ShieldUtils'; import { mkEvent } from '../../../test-utils'; import { IRoomState } from "../../../../src/components/structures/RoomView"; import RoomContext from '../../../../src/contexts/RoomContext'; - -describe('RoomHeader', () => { +import SdkConfig from "../../../../src/SdkConfig"; +import SettingsStore from "../../../../src/settings/SettingsStore"; +import { ElementCall, JitsiCall } from "../../../../src/models/Call"; +import { CallStore } from "../../../../src/stores/CallStore"; +import LegacyCallHandler from "../../../../src/LegacyCallHandler"; +import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../src/dispatcher/actions"; +import WidgetStore from "../../../../src/stores/WidgetStore"; + +describe('RoomHeader (Enzyme)', () => { it('shows the room avatar in a room with only ourselves', () => { // When we render a non-DM room with 1 person in it const room = createRoom({ name: "X Room", isDm: false, userIds: [] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -48,7 +73,7 @@ describe('RoomHeader', () => { // When we render a non-DM room with 2 people in it const room = createRoom( { name: "Y Room", isDm: false, userIds: ["other"] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -62,7 +87,7 @@ describe('RoomHeader', () => { it('shows the room avatar in a room with >2 people', () => { // When we render a non-DM room with 3 people in it const room = createRoom({ name: "Z Room", isDm: false, userIds: ["other1", "other2"] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -76,7 +101,7 @@ describe('RoomHeader', () => { it('shows the room avatar in a DM with only ourselves', () => { // When we render a non-DM room with 1 person in it const room = createRoom({ name: "Z Room", isDm: true, userIds: [] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -93,7 +118,7 @@ describe('RoomHeader', () => { // When we render a DM room with only 2 people in it const room = createRoom({ name: "Y Room", isDm: true, userIds: ["other"] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then we use the other user's avatar as our room's image avatar const image = findImg(rendered, ".mx_BaseAvatar_image"); @@ -106,8 +131,9 @@ describe('RoomHeader', () => { it('shows the room avatar in a DM with >2 people', () => { // When we render a DM room with 3 people in it const room = createRoom({ - name: "Z Room", isDm: true, userIds: ["other1", "other2"] }); - const rendered = render(room); + name: "Z Room", isDm: true, userIds: ["other1", "other2"], + }); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -120,7 +146,7 @@ describe('RoomHeader', () => { it("renders call buttons normally", () => { const room = createRoom({ name: "Room", isDm: false, userIds: ["other"] }); - const wrapper = render(room); + const wrapper = mountHeader(room); expect(wrapper.find('[aria-label="Voice call"]').hostNodes()).toHaveLength(1); expect(wrapper.find('[aria-label="Video call"]').hostNodes()).toHaveLength(1); @@ -128,7 +154,7 @@ describe('RoomHeader', () => { it("hides call buttons when the room is tombstoned", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room, {}, { + const wrapper = mountHeader(room, {}, { tombstone: mkEvent({ event: true, type: "m.room.tombstone", @@ -146,25 +172,25 @@ describe('RoomHeader', () => { it("should render buttons if not passing showButtons (default true)", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room); + const wrapper = mountHeader(room); expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(1); }); it("should not render buttons if passing showButtons = false", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room, { showButtons: false }); + const wrapper = mountHeader(room, { showButtons: false }); expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(0); }); it("should render the room options context menu if not passing enableRoomOptionsMenu (default true)", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room); + const wrapper = mountHeader(room); expect(wrapper.find(".mx_RoomHeader_name.mx_AccessibleButton")).toHaveLength(1); }); it("should not render the room options context menu if passing enableRoomOptionsMenu = false", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room, { enableRoomOptionsMenu: false }); + const wrapper = mountHeader(room, { enableRoomOptionsMenu: false }); expect(wrapper.find(".mx_RoomHeader_name.mx_AccessibleButton")).toHaveLength(0); }); }); @@ -176,7 +202,7 @@ interface IRoomCreationInfo { } function createRoom(info: IRoomCreationInfo) { - TestUtils.stubClient(); + stubClient(); const client: MatrixClient = MatrixClientPeg.get(); const roomId = '!1234567890:domain'; @@ -210,15 +236,15 @@ function createRoom(info: IRoomCreationInfo) { return room; } -function render(room: Room, propsOverride = {}, roomContext?: Partial): ReactWrapper { +function mountHeader(room: Room, propsOverride = {}, roomContext?: Partial): ReactWrapper { const props = { room, inRoom: true, - onSearchClick: () => {}, + onSearchClick: () => { }, onInviteClick: null, - onForgetClick: () => {}, + onForgetClick: () => { }, onCallPlaced: (_type) => { }, - onAppsClick: () => {}, + onAppsClick: () => { }, e2eStatus: E2EStatus.Normal, appsShown: true, searchInfo: { @@ -307,3 +333,395 @@ function findImg(wrapper: ReactWrapper, selector: string): ReactWrapper { expect(els).toHaveLength(1); return els.at(0); } + +describe("RoomHeader (React Testing Library)", () => { + let client: Mocked; + let room: Room; + let alice: RoomMember; + let bob: RoomMember; + let carol: RoomMember; + + beforeEach(async () => { + mockPlatformPeg({ supportsJitsiScreensharing: () => true }); + + stubClient(); + client = mocked(MatrixClientPeg.get()); + client.getUserId.mockReturnValue("@alice:example.org"); + + room = new Room("!1:example.org", client, "@alice:example.org", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + room.currentState.setStateEvents([mkCreationEvent(room.roomId, "@alice:example.org")]); + + client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); + client.getRooms.mockReturnValue([room]); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => { + if (roomId !== room.roomId) throw new Error("Unknown room"); + const event = mkEvent({ + event: true, + type: eventType, + room: roomId, + user: alice.userId, + skey: stateKey, + content, + }); + room.addLiveEvents([event]); + return { event_id: event.getId() }; + }); + + alice = mkRoomMember(room.roomId, "@alice:example.org"); + bob = mkRoomMember(room.roomId, "@bob:example.org"); + carol = mkRoomMember(room.roomId, "@carol:example.org"); + + client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); + client.getRooms.mockReturnValue([room]); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + + await Promise.all([CallStore.instance, WidgetStore.instance].map( + store => setupAsyncStoreWithClient(store, client), + )); + }); + + afterEach(async () => { + await Promise.all([CallStore.instance, WidgetStore.instance].map(resetAsyncStoreWithClient)); + client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); + jest.restoreAllMocks(); + SdkConfig.put({}); + }); + + const mockRoomType = (type: string) => { + jest.spyOn(room, "getType").mockReturnValue(type); + }; + const mockRoomMembers = (members: RoomMember[]) => { + jest.spyOn(room, "getJoinedMembers").mockReturnValue(members); + jest.spyOn(room, "getMember").mockImplementation( + userId => members.find(member => member.userId === userId) ?? null, + ); + }; + const mockEnabledSettings = (settings: string[]) => { + jest.spyOn(SettingsStore, "getValue").mockImplementation( + settingName => settings.includes(settingName), + ); + }; + const mockEventPowerLevels = (events: { [eventType: string]: number }) => { + room.currentState.setStateEvents([ + mkEvent({ + event: true, + type: EventType.RoomPowerLevels, + room: room.roomId, + user: alice.userId, + skey: "", + content: { events, state_default: 0 }, + }), + ]); + }; + const mockLegacyCall = () => { + jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue({} as unknown as MatrixCall); + }; + + const renderHeader = (props: Partial = {}, roomContext: Partial = {}) => { + render( + + { }} + onInviteClick={null} + onForgetClick={() => { }} + onAppsClick={() => { }} + e2eStatus={E2EStatus.Normal} + appsShown={true} + searchInfo={{ + searchTerm: "", + searchScope: SearchScope.Room, + searchCount: 0, + }} + {...props} + /> + , + ); + }; + + it("hides call buttons in video rooms", () => { + mockRoomType(RoomType.UnstableCall); + mockEnabledSettings(["showCallButtonsInComposer", "feature_video_rooms", "feature_element_call_video_rooms"]); + + renderHeader(); + expect(screen.queryByRole("button", { name: /call/i })).toBeNull(); + }); + + it("hides call buttons if showCallButtonsInComposer is disabled", () => { + mockEnabledSettings([]); + + renderHeader(); + expect(screen.queryByRole("button", { name: /call/i })).toBeNull(); + }); + + it( + "hides the voice call button and disables the video call button if configured to use Element Call exclusively " + + "and there's an ongoing call", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true } }); + await ElementCall.create(room); + + renderHeader(); + expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }, + ); + + it( + "hides the voice call button and starts Element calls when the video call button is pressed if configured to " + + "use Element Call exclusively", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true } }); + + renderHeader(); + expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); + + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + })); + defaultDispatcher.unregister(dispatcherRef); + }, + ); + + it( + "hides the voice call button and disables the video call button if configured to use Element Call exclusively " + + "and the user lacks permission", + () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true } }); + mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 }); + + renderHeader(); + expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }, + ); + + it("disables call buttons in the new group call experience if there's an ongoing Element call", async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + await ElementCall.create(room); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons in the new group call experience if there's an ongoing legacy 1:1 call", () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockLegacyCall(); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons in the new group call experience if there's an existing Jitsi widget", async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + await JitsiCall.create(room); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons in the new group call experience if there's no other members", () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it( + "starts legacy 1:1 calls when call buttons are pressed in the new group call experience if there's 1 other " + + "member", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob]); + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + }, + ); + + it( + "creates Jitsi widgets when call buttons are pressed in the new group call experience if the user lacks " + + "permission to start Element calls", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob, carol]); + mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 }); + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + }, + ); + + it( + "creates Jitsi widgets when the voice call button is pressed and shows a menu when the video call button is " + + "pressed in the new group call experience", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob, carol]); + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + // First try creating a Jitsi widget from the menu + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /jitsi/i })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + + // Then try starting an Element call from the menu + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /element/i })); + await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + })); + defaultDispatcher.unregister(dispatcherRef); + }, + ); + + it( + "disables the voice call button and starts Element calls when the video call button is pressed in the new " + + "group call experience when the user lacks permission to edit widgets", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob, carol]); + mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + })); + defaultDispatcher.unregister(dispatcherRef); + }, + ); + + it("disables call buttons in the new group call experience if the user lacks permission", () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob, carol]); + mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100, "im.vector.modular.widgets": 100 }); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons if there's an ongoing legacy 1:1 call", () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + mockLegacyCall(); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons if there's an existing Jitsi widget", async () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + await JitsiCall.create(room); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons if there's no other members", () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("starts legacy 1:1 calls when call buttons are pressed if there's 1 other member", async () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + mockRoomMembers([alice, bob]); + mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); // Just to verify that it doesn't try to use Jitsi + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + }); + + it("creates Jitsi widgets when call buttons are pressed", async () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + mockRoomMembers([alice, bob, carol]); + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + }); + + it("disables call buttons if the user lacks permission", () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + mockRoomMembers([alice, bob, carol]); + mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); +}); diff --git a/yarn.lock b/yarn.lock index b05ebda5522..e0043f44098 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27,6 +27,11 @@ dependencies: tunnel "^0.0.6" +"@adobe/css-tools@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.0.1.tgz#b38b444ad3aa5fedbb15f2f746dcd934226a12dd" + integrity sha512-+u76oB43nOHrF4DDWRLWDCtci7f3QJoEBigemIdIeTi1ODqjx6Tad9NCVnPRwewWlKkVab5PlK8DCtPTyX7S8g== + "@ampproject/remapping@^2.1.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" @@ -1259,6 +1264,13 @@ dependencies: jest-get-type "^28.0.2" +"@jest/expect-utils@^29.0.3": + version "29.0.3" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.0.3.tgz#f5bb86f5565bf2dacfca31ccbd887684936045b2" + integrity sha512-i1xUkau7K/63MpdwiRqaxgZOjxYs4f0WMTGJnYwUKubsNRZSeQbLorS7+I4uXVF9KQ5r61BUPAUMZ7Lf66l64Q== + dependencies: + jest-get-type "^29.0.0" + "@jest/fake-timers@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-27.5.1.tgz#76979745ce0579c8a94a4678af7a748eda8ada74" @@ -1318,6 +1330,13 @@ dependencies: "@sinclair/typebox" "^0.24.1" +"@jest/schemas@^29.0.0": + version "29.0.0" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.0.0.tgz#5f47f5994dd4ef067fb7b4188ceac45f77fe952a" + integrity sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA== + dependencies: + "@sinclair/typebox" "^0.24.1" + "@jest/source-map@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-27.5.1.tgz#6608391e465add4205eae073b55e7f279e04e8cf" @@ -1423,6 +1442,18 @@ "@types/yargs" "^17.0.8" chalk "^4.0.0" +"@jest/types@^29.0.3": + version "29.0.3" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.0.3.tgz#0be78fdddb1a35aeb2041074e55b860561c8ef63" + integrity sha512-coBJmOQvurXjN1Hh5PzF7cmsod0zLIOXpP8KD161mqNlroMhLcwpODiEzi7ZsRl5Z/AIuxpeNm8DCl43F4kz8A== + dependencies: + "@jest/schemas" "^29.0.0" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + "@jridgewell/gen-mapping@^0.1.0": version "0.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" @@ -1922,6 +1953,21 @@ lz-string "^1.4.4" pretty-format "^27.0.2" +"@testing-library/jest-dom@^5.16.5": + version "5.16.5" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz#3912846af19a29b2dbf32a6ae9c31ef52580074e" + integrity sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA== + dependencies: + "@adobe/css-tools" "^4.0.1" + "@babel/runtime" "^7.9.2" + "@types/testing-library__jest-dom" "^5.9.1" + aria-query "^5.0.0" + chalk "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.5.6" + lodash "^4.17.15" + redent "^3.0.0" + "@testing-library/react@^12.1.5": version "12.1.5" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" @@ -2090,6 +2136,14 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest@*": + version "29.0.3" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.0.3.tgz#b61a5ed100850686b8d3c5e28e3a1926b2001b59" + integrity sha512-F6ukyCTwbfsEX5F2YmVYmM5TcTHy1q9P5rWlRbrk56KyMh3v9xRGUO3aa8+SkvMi0SHXtASJv1283enXimC0Og== + dependencies: + expect "^29.0.0" + pretty-format "^29.0.0" + "@types/jest@^26.0.20": version "26.0.24" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.24.tgz#943d11976b16739185913a1936e0de0c4a7d595a" @@ -2259,6 +2313,13 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== +"@types/testing-library__jest-dom@^5.9.1": + version "5.14.5" + resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.5.tgz#d113709c90b3c75fdb127ec338dad7d5f86c974f" + integrity sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ== + dependencies: + "@types/jest" "*" + "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" @@ -3154,6 +3215,14 @@ chalk@^2.0.0: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^4.0.0, chalk@^4.1.0: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -3547,6 +3616,11 @@ css-what@^6.1.0: resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + csscolorparser@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/csscolorparser/-/csscolorparser-1.0.3.tgz#b34f391eea4da8f3e98231e2ccd8df9c041f171b" @@ -3815,6 +3889,11 @@ diff-sequences@^28.1.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-28.1.1.tgz#9989dc731266dc2903457a70e996f3a041913ac6" integrity sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw== +diff-sequences@^29.0.0: + version "29.0.0" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.0.0.tgz#bae49972ef3933556bcb0800b72e8579d19d9e4f" + integrity sha512-7Qe/zd1wxSDL4D/X/FPjOMB+ZMDt71W94KYaq05I2l0oQqgXgs7s4ftYYmV38gBSrPz2vcygxfs1xn0FT+rKNA== + dijkstrajs@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257" @@ -3846,7 +3925,7 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-accessibility-api@^0.5.9: +dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9: version "0.5.14" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz#56082f71b1dc7aac69d83c4285eef39c15d93f56" integrity sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg== @@ -4524,6 +4603,17 @@ expect@^28.1.0: jest-message-util "^28.1.3" jest-util "^28.1.3" +expect@^29.0.0: + version "29.0.3" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.0.3.tgz#6be65ddb945202f143c4e07c083f4f39f3bd326f" + integrity sha512-t8l5DTws3212VbmPL+tBFXhjRHLmctHB0oQbL8eUc6S7NzZtYUhycrFO9mkxA0ZUC6FAWdNi7JchJSkODtcu1Q== + dependencies: + "@jest/expect-utils" "^29.0.3" + jest-get-type "^29.0.0" + jest-matcher-utils "^29.0.3" + jest-message-util "^29.0.3" + jest-util "^29.0.3" + ext@^1.1.2: version "1.6.0" resolved "https://registry.yarnpkg.com/ext/-/ext-1.6.0.tgz#3871d50641e874cc172e2b53f919842d19db4c52" @@ -5860,6 +5950,16 @@ jest-diff@^28.1.3: jest-get-type "^28.0.2" pretty-format "^28.1.3" +jest-diff@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.0.3.tgz#41cc02409ad1458ae1bf7684129a3da2856341ac" + integrity sha512-+X/AIF5G/vX9fWK+Db9bi9BQas7M9oBME7egU7psbn4jlszLFCu0dW63UgeE6cs/GANq4fLaT+8sGHQQ0eCUfg== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.0.0" + jest-get-type "^29.0.0" + pretty-format "^29.0.3" + jest-docblock@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-27.5.1.tgz#14092f364a42c6108d42c33c8cf30e058e25f6c0" @@ -5926,6 +6026,11 @@ jest-get-type@^28.0.2: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-28.0.2.tgz#34622e628e4fdcd793d46db8a242227901fcf203" integrity sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA== +jest-get-type@^29.0.0: + version "29.0.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.0.0.tgz#843f6c50a1b778f7325df1129a0fd7aa713aef80" + integrity sha512-83X19z/HuLKYXYHskZlBAShO7UfLFXu/vWajw9ZNJASN32li8yHMaVGAQqxFW1RCFOkB7cubaL6FaJVQqqJLSw== + jest-haste-map@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" @@ -6018,6 +6123,16 @@ jest-matcher-utils@^28.1.3: jest-get-type "^28.0.2" pretty-format "^28.1.3" +jest-matcher-utils@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.0.3.tgz#b8305fd3f9e27cdbc210b21fc7dbba92d4e54560" + integrity sha512-RsR1+cZ6p1hDV4GSCQTg+9qjeotQCgkaleIKLK7dm+U4V/H2bWedU3RAtLm8+mANzZ7eDV33dMar4pejd7047w== + dependencies: + chalk "^4.0.0" + jest-diff "^29.0.3" + jest-get-type "^29.0.0" + pretty-format "^29.0.3" + jest-message-util@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-27.5.1.tgz#bdda72806da10d9ed6425e12afff38cd1458b6cf" @@ -6048,6 +6163,21 @@ jest-message-util@^28.1.3: slash "^3.0.0" stack-utils "^2.0.3" +jest-message-util@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.0.3.tgz#f0254e1ffad21890c78355726202cc91d0a40ea8" + integrity sha512-7T8JiUTtDfppojosORAflABfLsLKMLkBHSWkjNQrjIltGoDzNGn7wEPOSfjqYAGTYME65esQzMJxGDjuLBKdOg== + dependencies: + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.0.3" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.0.3" + slash "^3.0.0" + stack-utils "^2.0.3" + jest-mock@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-27.5.1.tgz#19948336d49ef4d9c52021d34ac7b5f36ff967d6" @@ -6243,6 +6373,18 @@ jest-util@^28.1.3: graceful-fs "^4.2.9" picomatch "^2.2.3" +jest-util@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.0.3.tgz#06d1d77f9a1bea380f121897d78695902959fbc0" + integrity sha512-Q0xaG3YRG8QiTC4R6fHjHQPaPpz9pJBEi0AeOE4mQh/FuWOijFjGXMMOfQEaU9i3z76cNR7FobZZUQnL6IyfdQ== + dependencies: + "@jest/types" "^29.0.3" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + jest-validate@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-27.5.1.tgz#9197d54dc0bdb52260b8db40b46ae668e04df067" @@ -6636,7 +6778,7 @@ lodash.truncate@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== -lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: +lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -7579,6 +7721,15 @@ pretty-format@^28.1.3: ansi-styles "^5.0.0" react-is "^18.0.0" +pretty-format@^29.0.0, pretty-format@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.0.3.tgz#23d5f8cabc9cbf209a77d49409d093d61166a811" + integrity sha512-cHudsvQr1K5vNVLbvYF/nv3Qy/F/BcEKxGuIeMiVMRHxPOO1RxXooP8g/ZrwAp7Dx+KdMZoOc7NxLHhMrP2f9Q== + dependencies: + "@jest/schemas" "^29.0.0" + ansi-styles "^5.0.0" + react-is "^18.0.0" + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" From 7e6d92c3ffca68815ba5b90b21fc069ca0aedc92 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Sat, 24 Sep 2022 21:42:51 -0400 Subject: [PATCH 10/10] Iterate code --- src/components/views/rooms/RoomHeader.tsx | 28 +++++++++---------- .../views/rooms/RoomHeader-test.tsx | 16 +++++------ 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 860d74e56e4..0d01e039c4f 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -74,7 +74,7 @@ interface VoiceCallButtonProps { room: Room; busy: boolean; setBusy: (value: boolean) => void; - behavior: DisabledWithReason | "legacy"; + behavior: DisabledWithReason | "legacy_or_jitsi"; } /** @@ -89,7 +89,7 @@ const VoiceCallButton: FC = ({ room, busy, setBusy, behavi tooltip: behavior.reason, disabled: true, }; - } else { + } else { // behavior === "legacy_or_jitsi" return { onClick: async (ev: ButtonEvent) => { ev.preventDefault(); @@ -115,7 +115,7 @@ interface VideoCallButtonProps { room: Room; busy: boolean; setBusy: (value: boolean) => void; - behavior: DisabledWithReason | "legacy" | "element" | "legacy_or_element"; + behavior: DisabledWithReason | "legacy_or_jitsi" | "element" | "jitsi_or_element"; } /** @@ -150,7 +150,7 @@ const VideoCallButton: FC = ({ room, busy, setBusy, behavi tooltip: behavior.reason, disabled: true, }; - } else if (behavior === "legacy") { + } else if (behavior === "legacy_or_jitsi") { return { onClick: async (ev: ButtonEvent) => { ev.preventDefault(); @@ -166,7 +166,7 @@ const VideoCallButton: FC = ({ room, busy, setBusy, behavi }, disabled: false, }; - } else { + } else { // behavior === "jitsi_or_element" return { onClick: async (ev: ButtonEvent) => { ev.preventDefault(); @@ -236,7 +236,7 @@ const CallButtons: FC = ({ room }) => { const widgets = useWidgets(room); const hasJitsiWidget = useMemo(() => widgets.some(widget => WidgetType.JITSI.matches(widget.type)), [widgets]); - const hasElementCall = useCall(room.roomId) !== null; + const hasGroupCall = useCall(room.roomId) !== null; const [functionalMembers, mayEditWidgets, mayCreateElementCalls] = useTypedEventEmitterState( room, @@ -257,7 +257,7 @@ const CallButtons: FC = ({ room }) => { return null; } else if (groupCallsEnabled) { if (useElementCallExclusively) { - if (hasElementCall) { + if (hasGroupCall) { return makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))); } else if (mayCreateElementCalls) { return makeVideoCallButton("element"); @@ -266,7 +266,7 @@ const CallButtons: FC = ({ room }) => { new DisabledWithReason(_t("You do not have permission to start video calls")), ); } - } else if (hasLegacyCall || hasJitsiWidget || hasElementCall) { + } else if (hasLegacyCall || hasJitsiWidget || hasGroupCall) { return <> { makeVoiceCallButton(new DisabledWithReason(_t("Ongoing call"))) } { makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))) } @@ -278,13 +278,13 @@ const CallButtons: FC = ({ room }) => { ; } else if (functionalMembers.length === 2) { return <> - { makeVoiceCallButton("legacy") } - { makeVideoCallButton("legacy") } + { makeVoiceCallButton("legacy_or_jitsi") } + { makeVideoCallButton("legacy_or_jitsi") } ; } else if (mayEditWidgets) { return <> - { makeVoiceCallButton("legacy") } - { makeVideoCallButton(mayCreateElementCalls ? "legacy_or_element" : "legacy") } + { makeVoiceCallButton("legacy_or_jitsi") } + { makeVideoCallButton(mayCreateElementCalls ? "jitsi_or_element" : "legacy_or_jitsi") } ; } else { const videoCallBehavior = mayCreateElementCalls @@ -307,8 +307,8 @@ const CallButtons: FC = ({ room }) => { ; } else if (functionalMembers.length === 2 || mayEditWidgets) { return <> - { makeVoiceCallButton("legacy") } - { makeVideoCallButton("legacy") } + { makeVoiceCallButton("legacy_or_jitsi") } + { makeVideoCallButton("legacy_or_jitsi") } ; } else { return <> diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index f1f9002d1fe..7181f143c3e 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -473,7 +473,7 @@ describe("RoomHeader (React Testing Library)", () => { ); it( - "hides the voice call button and starts Element calls when the video call button is pressed if configured to " + "hides the voice call button and starts an Element call when the video call button is pressed if configured to " + "use Element Call exclusively", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); @@ -544,7 +544,7 @@ describe("RoomHeader (React Testing Library)", () => { }); it( - "starts legacy 1:1 calls when call buttons are pressed in the new group call experience if there's 1 other " + "starts a legacy 1:1 call when call buttons are pressed in the new group call experience if there's 1 other " + "member", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); @@ -565,7 +565,7 @@ describe("RoomHeader (React Testing Library)", () => { ); it( - "creates Jitsi widgets when call buttons are pressed in the new group call experience if the user lacks " + "creates a Jitsi widget when call buttons are pressed in the new group call experience if the user lacks " + "permission to start Element calls", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); @@ -587,7 +587,7 @@ describe("RoomHeader (React Testing Library)", () => { ); it( - "creates Jitsi widgets when the voice call button is pressed and shows a menu when the video call button is " + "creates a Jitsi widget when the voice call button is pressed and shows a menu when the video call button is " + "pressed in the new group call experience", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); @@ -622,8 +622,8 @@ describe("RoomHeader (React Testing Library)", () => { ); it( - "disables the voice call button and starts Element calls when the video call button is pressed in the new " - + "group call experience when the user lacks permission to edit widgets", + "disables the voice call button and starts an Element call when the video call button is pressed in the new " + + "group call experience if the user lacks permission to edit widgets", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); mockRoomMembers([alice, bob, carol]); @@ -680,7 +680,7 @@ describe("RoomHeader (React Testing Library)", () => { expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); }); - it("starts legacy 1:1 calls when call buttons are pressed if there's 1 other member", async () => { + it("starts a legacy 1:1 call when call buttons are pressed if there's 1 other member", async () => { mockEnabledSettings(["showCallButtonsInComposer"]); mockRoomMembers([alice, bob]); mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); // Just to verify that it doesn't try to use Jitsi @@ -698,7 +698,7 @@ describe("RoomHeader (React Testing Library)", () => { expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); }); - it("creates Jitsi widgets when call buttons are pressed", async () => { + it("creates a Jitsi widget when call buttons are pressed", async () => { mockEnabledSettings(["showCallButtonsInComposer"]); mockRoomMembers([alice, bob, carol]);