From 3acc9059ab5aa893fee6a603ea79fea0a91961df Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 23 Aug 2023 15:13:40 +0100 Subject: [PATCH] Add Voice and Video call in room header (#11444) * Add Voice and Video call in room header * Add thread icon in room header * Add room notification icon to room header * Fix linting * Add tests for buttons in room header * Add JSDoc * micro optimisations * Fix call disabled when hanging up * Fix disabled state change on members count update * Exclude functional members from members count optionally * i18n --- package.json | 2 +- res/css/components/views/utils/_Box.pcss | 14 +- res/css/components/views/utils/_Flex.pcss | 10 +- res/css/views/rooms/_RoomHeader.pcss | 8 +- src/components/utils/Box.tsx | 14 +- src/components/views/rooms/RoomHeader.tsx | 147 +++++++++-- src/hooks/room/useRoomCallStatus.ts | 161 ++++++++++++ src/hooks/room/useRoomThreadNotifications.ts | 67 +++++ src/hooks/useGlobalNotificationState.ts | 44 ++++ src/hooks/useRoomMembers.ts | 33 ++- src/i18n/strings/en_EN.json | 9 +- .../views/rooms/RoomHeader-test.tsx | 229 +++++++++++++++++- yarn.lock | 19 +- 13 files changed, 709 insertions(+), 48 deletions(-) create mode 100644 src/hooks/room/useRoomCallStatus.ts create mode 100644 src/hooks/room/useRoomThreadNotifications.ts create mode 100644 src/hooks/useGlobalNotificationState.ts diff --git a/package.json b/package.json index 6580f806e9..ff012cff41 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "@sentry/browser": "^7.0.0", "@sentry/tracing": "^7.0.0", "@testing-library/react-hooks": "^8.0.1", - "@vector-im/compound-design-tokens": "^0.0.3", + "@vector-im/compound-design-tokens": "^0.0.4", "@vector-im/compound-web": "^0.2.3", "await-lock": "^2.1.0", "blurhash": "^1.1.3", diff --git a/res/css/components/views/utils/_Box.pcss b/res/css/components/views/utils/_Box.pcss index 97de604e62..a8ab7e9455 100644 --- a/res/css/components/views/utils/_Box.pcss +++ b/res/css/components/views/utils/_Box.pcss @@ -14,8 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_Box { - flex: var(--mx-box-flex, initial); - flex-shrink: var(--mx-box-shrink, initial); - flex-grow: var(--mx-box-grow, initial); +.mx_Box--flex { + flex: var(--mx-box-flex, unset); +} + +.mx_Box--shrink { + flex-shrink: var(--mx-box-shrink, unset); +} + +.mx_Box--grow { + flex-grow: var(--mx-box-grow, unset); } diff --git a/res/css/components/views/utils/_Flex.pcss b/res/css/components/views/utils/_Flex.pcss index 6094476d38..f9cdc7e3cc 100644 --- a/res/css/components/views/utils/_Flex.pcss +++ b/res/css/components/views/utils/_Flex.pcss @@ -15,9 +15,9 @@ limitations under the License. */ .mx_Flex { - display: var(--mx-flex-display, initial); - flex-direction: var(--mx-flex-direction, initial); - align-items: var(--mx-flex-align, initial); - justify-content: var(--mx-flex-justify, initial); - gap: var(--mx-flex-gap, initial); + display: var(--mx-flex-display, unset); + flex-direction: var(--mx-flex-direction, unset); + align-items: var(--mx-flex-align, unset); + justify-content: var(--mx-flex-justify, unset); + gap: var(--mx-flex-gap, unset); } diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss index 3a0b73b666..e545649464 100644 --- a/res/css/views/rooms/_RoomHeader.pcss +++ b/res/css/views/rooms/_RoomHeader.pcss @@ -19,10 +19,10 @@ limitations under the License. padding: 0 var(--cpd-space-3x); border-bottom: 1px solid $separator; background-color: $background; +} - &:hover { - cursor: pointer; - } +.mx_RoomHeader_info { + cursor: pointer; } .mx_RoomHeader_topic { @@ -36,7 +36,7 @@ limitations under the License. word-break: break-all; text-overflow: ellipsis; - transition: all var(--transition-standard) ease; + transition: all var(--transition-standard) ease 0.1s; } .mx_RoomHeader:hover .mx_RoomHeader_topic { diff --git a/src/components/utils/Box.tsx b/src/components/utils/Box.tsx index 9e91858b50..03fffb90ef 100644 --- a/src/components/utils/Box.tsx +++ b/src/components/utils/Box.tsx @@ -87,5 +87,17 @@ export function Box({ addOrRemoveProperty(ref, `--mx-box-grow`, grow); }, [flex, grow, shrink]); - return React.createElement(as, { ...props, className: classNames("mx_Box", className), ref }, children); + return React.createElement( + as, + { + ...props, + className: classNames("mx_Box", className, { + "mx_Box--flex": !!flex, + "mx_Box--shrink": !!shrink, + "mx_Box--grow": !!grow, + }), + ref, + }, + children, + ); } diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index d6bf5cbf95..cddb541bd8 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -14,10 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; -import { Body as BodyText } from "@vector-im/compound-web"; +import React, { useCallback, useMemo } from "react"; +import { Body as BodyText, IconButton } from "@vector-im/compound-web"; +import { Icon as VideoCallIcon } from "@vector-im/compound-design-tokens/icons/video-call.svg"; +import { Icon as VoiceCallIcon } from "@vector-im/compound-design-tokens/icons/voice-call.svg"; +import { Icon as ThreadsIcon } from "@vector-im/compound-design-tokens/icons/threads-solid.svg"; +import { Icon as NotificationsIcon } from "@vector-im/compound-design-tokens/icons/notifications-solid.svg"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; import type { Room } from "matrix-js-sdk/src/matrix"; +import { _t } from "../../../languageHandler"; import { useRoomName } from "../../../hooks/useRoomName"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; @@ -25,26 +31,97 @@ import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { useTopic } from "../../../hooks/room/useTopic"; import { Flex } from "../../utils/Flex"; import { Box } from "../../utils/Box"; +import { useRoomCallStatus } from "../../../hooks/room/useRoomCallStatus"; +import LegacyCallHandler from "../../../LegacyCallHandler"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../../../dispatcher/actions"; +import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications"; +import { NotificationColor } from "../../../stores/notifications/NotificationColor"; +import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState"; +import SdkConfig from "../../../SdkConfig"; +import { useFeatureEnabled } from "../../../hooks/useSettings"; + +/** + * A helper to transform a notification color to the what the Compound Icon Button + * expects + */ +function notificationColorToIndicator(color: NotificationColor): React.ComponentProps["indicator"] { + if (color <= NotificationColor.None) { + return undefined; + } else if (color <= NotificationColor.Grey) { + return "default"; + } else { + return "highlight"; + } +} + +/** + * A helper to show or hide the right panel + */ +function showOrHidePanel(phase: RightPanelPhases): void { + const rightPanel = RightPanelStore.instance; + rightPanel.isOpen && rightPanel.currentCard.phase === phase + ? rightPanel.togglePanel(null) + : rightPanel.setCard({ phase }); +} export default function RoomHeader({ room }: { room: Room }): JSX.Element { const roomName = useRoomName(room); const roomTopic = useTopic(room); + const { voiceCallDisabledReason, voiceCallType, videoCallDisabledReason, videoCallType } = useRoomCallStatus(room); + + const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); + /** + * A special mode where only Element Call is used. In this case we want to + * hide the voice call button + */ + const useElementCallExclusively = useMemo(() => { + return SdkConfig.get("element_call").use_exclusively && groupCallsEnabled; + }, [groupCallsEnabled]); + + const placeCall = useCallback( + async (callType: CallType, platformCallType: typeof voiceCallType) => { + switch (platformCallType) { + case "legacy_or_jitsi": + await LegacyCallHandler.instance.placeCall(room.roomId, callType); + break; + // TODO: Remove the jitsi_or_element_call case and + // use the commented code below + case "element_call": + case "jitsi_or_element_call": + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + metricsTrigger: undefined, + }); + break; + + // case "jitsi_or_element_call": + // TODO: Open dropdown menu to choice between + // EC and Jitsi. Waiting on Compound's dropdown + // component + // break; + } + }, + [room.roomId], + ); + + const threadNotifications = useRoomThreadNotifications(room); + const globalNotificationState = useGlobalNotificationState(); + return ( - { - const rightPanel = RightPanelStore.instance; - rightPanel.isOpen - ? rightPanel.togglePanel(null) - : rightPanel.setCard({ phase: RightPanelPhases.RoomSummary }); - }} - > + - + { + showOrHidePanel(RightPanelPhases.RoomSummary); + }} + > )} + + {!useElementCallExclusively && ( + { + placeCall(CallType.Voice, voiceCallType); + }} + > + + + )} + { + placeCall(CallType.Video, videoCallType); + }} + > + + + { + showOrHidePanel(RightPanelPhases.ThreadPanel); + }} + title={_t("Threads")} + > + + + { + showOrHidePanel(RightPanelPhases.NotificationPanel); + }} + title={_t("Notifications")} + > + + + ); } diff --git a/src/hooks/room/useRoomCallStatus.ts b/src/hooks/room/useRoomCallStatus.ts new file mode 100644 index 0000000000..dcd5bdc4f0 --- /dev/null +++ b/src/hooks/room/useRoomCallStatus.ts @@ -0,0 +1,161 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { useFeatureEnabled } from "../useSettings"; +import SdkConfig from "../../SdkConfig"; +import { useEventEmitterState, useTypedEventEmitterState } from "../useEventEmitter"; +import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler"; +import { useWidgets } from "../../components/views/right_panel/RoomSummaryCard"; +import { WidgetType } from "../../widgets/WidgetType"; +import { useCall } from "../useCall"; +import { _t } from "../../languageHandler"; +import { useRoomMemberCount } from "../useRoomMembers"; +import { ElementCall } from "../../models/Call"; + +type CallType = "element_call" | "jitsi_or_element_call" | "legacy_or_jitsi"; + +const DEFAULT_DISABLED_REASON = null; +const DEFAULT_CALL_TYPE = "jitsi_or_element_call"; + +/** + * Reports the call capabilities for the current room + * @param room the room to track + * @returns the call status for a room + */ +export const useRoomCallStatus = ( + room: Room, +): { + voiceCallDisabledReason: string | null; + voiceCallType: CallType; + videoCallDisabledReason: string | null; + videoCallType: CallType; +} => { + const [voiceCallDisabledReason, setVoiceCallDisabledReason] = useState(DEFAULT_DISABLED_REASON); + const [videoCallDisabledReason, setVideoCallDisabledReason] = useState(DEFAULT_DISABLED_REASON); + const [voiceCallType, setVoiceCallType] = useState(DEFAULT_CALL_TYPE); + const [videoCallType, setVideoCallType] = useState(DEFAULT_CALL_TYPE); + + const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); + const useElementCallExclusively = useMemo(() => { + return SdkConfig.get("element_call").use_exclusively; + }, []); + + const hasLegacyCall = useEventEmitterState( + LegacyCallHandler.instance, + LegacyCallHandlerEvent.CallsChanged, + () => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null, + ); + + const widgets = useWidgets(room); + const hasJitsiWidget = useMemo(() => widgets.some((widget) => WidgetType.JITSI.matches(widget.type)), [widgets]); + + const hasGroupCall = useCall(room.roomId) !== null; + + const memberCount = useRoomMemberCount(room, { includeFunctional: false }); + + const [mayEditWidgets, mayCreateElementCalls] = useTypedEventEmitterState( + room, + RoomStateEvent.Update, + useCallback( + () => [ + room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client), + room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client), + ], + [room], + ), + ); + + useEffect(() => { + // First reset all state to their default value + setVoiceCallDisabledReason(DEFAULT_DISABLED_REASON); + setVideoCallDisabledReason(DEFAULT_DISABLED_REASON); + setVoiceCallType(DEFAULT_CALL_TYPE); + setVideoCallType(DEFAULT_CALL_TYPE); + + // And then run the logic to figure out their correct state + if (groupCallsEnabled) { + if (useElementCallExclusively) { + if (hasGroupCall) { + setVideoCallDisabledReason(_t("Ongoing call")); + } else if (mayCreateElementCalls) { + setVideoCallType("element_call"); + } else { + setVideoCallDisabledReason(_t("You do not have permission to start video calls")); + } + } else if (hasLegacyCall || hasJitsiWidget || hasGroupCall) { + setVoiceCallDisabledReason(_t("Ongoing call")); + setVideoCallDisabledReason(_t("Ongoing call")); + } else if (memberCount <= 1) { + setVoiceCallDisabledReason(_t("There's no one here to call")); + setVideoCallDisabledReason(_t("There's no one here to call")); + } else if (memberCount === 2) { + setVoiceCallType("legacy_or_jitsi"); + setVideoCallType("legacy_or_jitsi"); + } else if (mayEditWidgets) { + setVoiceCallType("legacy_or_jitsi"); + setVideoCallType(mayCreateElementCalls ? "jitsi_or_element_call" : "legacy_or_jitsi"); + } else { + setVoiceCallDisabledReason(_t("You do not have permission to start voice calls")); + if (mayCreateElementCalls) { + setVideoCallType("element_call"); + } else { + setVideoCallDisabledReason(_t("You do not have permission to start video calls")); + } + } + } else if (hasLegacyCall || hasJitsiWidget) { + setVoiceCallDisabledReason(_t("Ongoing call")); + setVideoCallDisabledReason(_t("Ongoing call")); + } else if (memberCount <= 1) { + setVoiceCallDisabledReason(_t("There's no one here to call")); + setVideoCallDisabledReason(_t("There's no one here to call")); + } else if (memberCount === 2 || mayEditWidgets) { + setVoiceCallType("legacy_or_jitsi"); + setVideoCallType("legacy_or_jitsi"); + } else { + setVoiceCallDisabledReason(_t("You do not have permission to start voice calls")); + setVideoCallDisabledReason(_t("You do not have permission to start video calls")); + } + }, [ + memberCount, + groupCallsEnabled, + hasGroupCall, + hasJitsiWidget, + hasLegacyCall, + mayCreateElementCalls, + mayEditWidgets, + useElementCallExclusively, + ]); + + console.table({ + voiceCallDisabledReason, + voiceCallType, + videoCallDisabledReason, + videoCallType, + }); + + /** + * We've gone through all the steps + */ + return { + voiceCallDisabledReason, + voiceCallType, + videoCallDisabledReason, + videoCallType, + }; +}; diff --git a/src/hooks/room/useRoomThreadNotifications.ts b/src/hooks/room/useRoomThreadNotifications.ts new file mode 100644 index 0000000000..10dcdad513 --- /dev/null +++ b/src/hooks/room/useRoomThreadNotifications.ts @@ -0,0 +1,67 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { NotificationCountType, Room, RoomEvent, ThreadEvent } from "matrix-js-sdk/src/matrix"; +import { useCallback, useEffect, useState } from "react"; + +import { NotificationColor } from "../../stores/notifications/NotificationColor"; +import { doesRoomOrThreadHaveUnreadMessages } from "../../Unread"; +import { useEventEmitter } from "../useEventEmitter"; + +/** + * Tracks the thread unread state for an entire room + * @param room the room to track + * @returns the type of notification for this room + */ +export const useRoomThreadNotifications = (room: Room): NotificationColor => { + const [notificationColor, setNotificationColor] = useState(NotificationColor.None); + + const updateNotification = useCallback(() => { + switch (room?.threadsAggregateNotificationType) { + case NotificationCountType.Highlight: + setNotificationColor(NotificationColor.Red); + break; + case NotificationCountType.Total: + setNotificationColor(NotificationColor.Grey); + break; + } + // We don't have any notified messages, but we might have unread messages. Let's + // find out. + for (const thread of room!.getThreads()) { + // If the current thread has unread messages, we're done. + if (doesRoomOrThreadHaveUnreadMessages(thread)) { + setNotificationColor(NotificationColor.Bold); + break; + } + } + }, [room]); + + useEventEmitter(room, RoomEvent.UnreadNotifications, updateNotification); + useEventEmitter(room, RoomEvent.Receipt, updateNotification); + useEventEmitter(room, RoomEvent.Timeline, updateNotification); + useEventEmitter(room, RoomEvent.Redaction, updateNotification); + useEventEmitter(room, RoomEvent.LocalEchoUpdated, updateNotification); + useEventEmitter(room, RoomEvent.MyMembership, updateNotification); + useEventEmitter(room, ThreadEvent.New, updateNotification); + useEventEmitter(room, ThreadEvent.Update, updateNotification); + + // Compute the notification once when mouting a room + useEffect(() => { + updateNotification(); + }, [updateNotification]); + + return notificationColor; +}; diff --git a/src/hooks/useGlobalNotificationState.ts b/src/hooks/useGlobalNotificationState.ts new file mode 100644 index 0000000000..4417eb6093 --- /dev/null +++ b/src/hooks/useGlobalNotificationState.ts @@ -0,0 +1,44 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useState } from "react"; + +import { SummarizedNotificationState } from "../stores/notifications/SummarizedNotificationState"; +import { + RoomNotificationStateStore, + UPDATE_STATUS_INDICATOR, +} from "../stores/notifications/RoomNotificationStateStore"; +import { useEventEmitter } from "./useEventEmitter"; + +/** + * Tracks the global notification state of the user's account + * @returns A global notification state object + */ +export const useGlobalNotificationState = (): SummarizedNotificationState => { + const [summarizedNotificationState, setSummarizedNotificationState] = useState( + RoomNotificationStateStore.instance.globalState, + ); + + useEventEmitter( + RoomNotificationStateStore.instance, + UPDATE_STATUS_INDICATOR, + (notificationState: SummarizedNotificationState) => { + setSummarizedNotificationState(notificationState); + }, + ); + + return summarizedNotificationState; +}; diff --git a/src/hooks/useRoomMembers.ts b/src/hooks/useRoomMembers.ts index 0e037c990e..b9db87bc2d 100644 --- a/src/hooks/useRoomMembers.ts +++ b/src/hooks/useRoomMembers.ts @@ -19,6 +19,7 @@ import { Room, RoomEvent, RoomMember, RoomStateEvent } from "matrix-js-sdk/src/m import { throttle } from "lodash"; import { useTypedEventEmitter } from "./useEventEmitter"; +import { getJoinedNonFunctionalMembers } from "../utils/room/getJoinedNonFunctionalMembers"; // Hook to simplify watching Matrix Room joined members export const useRoomMembers = (room: Room, throttleWait = 250): RoomMember[] => { @@ -37,15 +38,43 @@ export const useRoomMembers = (room: Room, throttleWait = 250): RoomMember[] => return members; }; +type RoomMemberCountOpts = { + /** + * Wait time between room member count update + */ + throttleWait?: number; + /** + * Whether to include functional members (bots, etc...) in the room count + * @default true + */ + includeFunctional: boolean; +}; + // Hook to simplify watching Matrix Room joined member count -export const useRoomMemberCount = (room: Room, throttleWait = 250): number => { +export const useRoomMemberCount = ( + room: Room, + opts: RoomMemberCountOpts = { throttleWait: 250, includeFunctional: true }, +): number => { const [count, setCount] = useState(room.getJoinedMemberCount()); + + const { throttleWait, includeFunctional } = opts; + useTypedEventEmitter( room.currentState, RoomStateEvent.Members, throttle( () => { - setCount(room.getJoinedMemberCount()); + // At the time where `RoomStateEvent.Members` is emitted the + // summary API has not had a chance to update the `summaryJoinedMemberCount` + // value, therefore handling the logic locally here. + // + // Tracked as part of https://github.com/vector-im/element-web/issues/26033 + const membersCount = includeFunctional + ? room.getMembers().reduce((count, m) => { + return m.membership === "join" ? count + 1 : count; + }, 0) + : getJoinedNonFunctionalMembers(room).length; + setCount(membersCount); }, throttleWait, { leading: true, trailing: true }, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index cf1863c844..e1bc2ee44e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -954,6 +954,10 @@ "Turn on notifications": "Turn on notifications", "Don’t miss a reply or important message": "Don’t miss a reply or important message", "Enable notifications": "Enable notifications", + "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", "Sends the given message with confetti": "Sends the given message with confetti", "sends confetti": "sends confetti", "Sends the given message with fireworks": "Sends the given message with fireworks", @@ -1716,10 +1720,6 @@ "Scroll to most recent messages": "Scroll to most recent messages", "Video call (Jitsi)": "Video call (Jitsi)", "Video call (%(brand)s)": "Video call (%(brand)s)", - "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", "Freedom": "Freedom", "Spotlight": "Spotlight", "Change layout": "Change layout", @@ -1815,6 +1815,7 @@ "Room %(name)s": "Room %(name)s", "Recently visited rooms": "Recently visited rooms", "No recently visited rooms": "No recently visited rooms", + "Threads": "Threads", "Video room": "Video room", "Public space": "Public space", "Public room": "Public room", diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index 54c9c56f45..73317a054e 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -15,9 +15,10 @@ limitations under the License. */ import React from "react"; -import { render } from "@testing-library/react"; -import { Room, EventType, MatrixEvent, PendingEventOrdering } from "matrix-js-sdk/src/matrix"; +import { getAllByTitle, getByText, getByTitle, render, screen } from "@testing-library/react"; +import { Room, EventType, MatrixEvent, PendingEventOrdering, MatrixCall } from "matrix-js-sdk/src/matrix"; import userEvent from "@testing-library/user-event"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { stubClient } from "../../../test-utils"; import RoomHeader from "../../../../src/components/views/rooms/RoomHeader"; @@ -25,6 +26,12 @@ import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import RightPanelStore from "../../../../src/stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases"; +import LegacyCallHandler from "../../../../src/LegacyCallHandler"; +import SettingsStore from "../../../../src/settings/SettingsStore"; +import SdkConfig from "../../../../src/SdkConfig"; +import dispatcher from "../../../../src/dispatcher/dispatcher"; +import { CallStore } from "../../../../src/stores/CallStore"; +import { Call, ElementCall } from "../../../../src/models/Call"; describe("Roomeader", () => { let room: Room; @@ -45,6 +52,10 @@ describe("Roomeader", () => { setCardSpy = jest.spyOn(RightPanelStore.instance, "setCard"); }); + afterEach(() => { + jest.resetAllMocks(); + }); + it("renders the room header", () => { const { container } = render(); expect(container).toHaveTextContent(ROOM_ID); @@ -71,7 +82,219 @@ describe("Roomeader", () => { it("opens the room summary", async () => { const { container } = render(); - await userEvent.click(container.firstChild! as Element); + await userEvent.click(getByText(container, ROOM_ID)); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary }); }); + + it("opens the thread panel", async () => { + const { container } = render(); + + await userEvent.click(getByTitle(container, "Threads")); + expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.ThreadPanel }); + }); + + it("opens the notifications panel", async () => { + const { container } = render(); + + await userEvent.click(getByTitle(container, "Notifications")); + expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.NotificationPanel }); + }); + + describe("groups call disabled", () => { + it("you can't call if you're alone", () => { + mockRoomMembers(room, 1); + const { container } = render(); + for (const button of getAllByTitle(container, "There's no one here to call")) { + expect(button).toBeDisabled(); + } + }); + + it("you can call when you're two in the room", async () => { + mockRoomMembers(room, 2); + const { container } = render(); + const voiceButton = getByTitle(container, "Voice call"); + const videoButton = getByTitle(container, "Video call"); + expect(voiceButton).not.toBeDisabled(); + expect(videoButton).not.toBeDisabled(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); + + await userEvent.click(voiceButton); + expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); + + await userEvent.click(videoButton); + expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); + }); + + it("you can't call if there's already a call", () => { + mockRoomMembers(room, 2); + jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue( + // The JS-SDK does not export the class `MatrixCall` only the type + {} as MatrixCall, + ); + const { container } = render(); + for (const button of getAllByTitle(container, "Ongoing call")) { + expect(button).toBeDisabled(); + } + }); + + it("can calls in large rooms if able to edit widgets", () => { + mockRoomMembers(room, 10); + jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + const { container } = render(); + + expect(getByTitle(container, "Voice call")).not.toBeDisabled(); + expect(getByTitle(container, "Video call")).not.toBeDisabled(); + }); + + it("disable calls in large rooms by default", () => { + mockRoomMembers(room, 10); + jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(false); + const { container } = render(); + expect(getByTitle(container, "You do not have permission to start voice calls")).toBeDisabled(); + expect(getByTitle(container, "You do not have permission to start video calls")).toBeDisabled(); + }); + }); + + describe("group call enabled", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature === "feature_group_calls"); + }); + + it("renders only the video call element", async () => { + jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true }); + // allow element calls + jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + + const { container } = render(); + + expect(screen.queryByTitle("Voice call")).toBeNull(); + + const videoCallButton = getByTitle(container, "Video call"); + expect(videoCallButton).not.toBeDisabled(); + + const dispatcherSpy = jest.spyOn(dispatcher, "dispatch"); + + await userEvent.click(getByTitle(container, "Video call")); + + expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true })); + }); + + it("can call if there's an ongoing call", () => { + jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true }); + // allow element calls + jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + + jest.spyOn(CallStore.instance, "getCall").mockReturnValue({} as Call); + + const { container } = render(); + expect(getByTitle(container, "Ongoing call")).toBeDisabled(); + }); + + it("disables calling if there's a jitsi call", () => { + mockRoomMembers(room, 2); + jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue( + // The JS-SDK does not export the class `MatrixCall` only the type + {} as MatrixCall, + ); + const { container } = render(); + for (const button of getAllByTitle(container, "Ongoing call")) { + expect(button).toBeDisabled(); + } + }); + + it("can't call if you have no friends", () => { + mockRoomMembers(room, 1); + const { container } = render(); + for (const button of getAllByTitle(container, "There's no one here to call")) { + expect(button).toBeDisabled(); + } + }); + + it("calls using legacy or jitsi", async () => { + mockRoomMembers(room, 2); + const { container } = render(); + + const voiceButton = getByTitle(container, "Voice call"); + const videoButton = getByTitle(container, "Video call"); + expect(voiceButton).not.toBeDisabled(); + expect(videoButton).not.toBeDisabled(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); + await userEvent.click(voiceButton); + expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); + + await userEvent.click(videoButton); + expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); + }); + + it("calls using legacy or jitsi for large rooms", async () => { + mockRoomMembers(room, 3); + + jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { + if (key === "im.vector.modular.widgets") return true; + return false; + }); + + const { container } = render(); + + const voiceButton = getByTitle(container, "Voice call"); + const videoButton = getByTitle(container, "Video call"); + expect(voiceButton).not.toBeDisabled(); + expect(videoButton).not.toBeDisabled(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); + await userEvent.click(voiceButton); + expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); + + await userEvent.click(videoButton); + expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); + }); + + it("calls using element calls for large rooms", async () => { + mockRoomMembers(room, 3); + + jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => { + if (key === "im.vector.modular.widgets") return true; + if (key === ElementCall.CALL_EVENT_TYPE.name) return true; + return false; + }); + + const { container } = render(); + + const voiceButton = getByTitle(container, "Voice call"); + const videoButton = getByTitle(container, "Video call"); + expect(voiceButton).not.toBeDisabled(); + expect(videoButton).not.toBeDisabled(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); + await userEvent.click(voiceButton); + expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); + + const dispatcherSpy = jest.spyOn(dispatcher, "dispatch"); + await userEvent.click(videoButton); + expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true })); + }); + }); }); + +/** + * + * @param count the number of users to create + */ +function mockRoomMembers(room: Room, count: number) { + const members = Array(count) + .fill(0) + .map((_, index) => ({ + userId: `@user-${index}:example.org`, + name: `Member ${index}`, + rawDisplayName: `Member ${index}`, + roomId: room.roomId, + membership: "join", + getAvatarUrl: () => `mxc://avatar.url/user-${index}.png`, + getMxcAvatarUrl: () => `mxc://avatar.url/user-${index}.png`, + })); + + room.currentState.setJoinedMemberCount(members.length); + room.getJoinedMembers = jest.fn().mockReturnValue(members); +} diff --git a/yarn.lock b/yarn.lock index d6975aa278..db03a414e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2912,21 +2912,22 @@ "@typescript-eslint/types" "5.62.0" eslint-visitor-keys "^3.3.0" -"@vector-im/compound-design-tokens@^0.0.3": - version "0.0.3" - resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-0.0.3.tgz#89214c69108a14f5d3e4a73ddc44852862531f2b" - integrity sha512-XxmySUvfjD6EuAM7f6lsGIhuv94TFfoEpKxYh+HKn1hPBFcMEKKImu/jK5tnpOv2xuZOSrK0Pm6qMLnxLwOXOw== +"@vector-im/compound-design-tokens@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-0.0.4.tgz#bf31120f026118d9dc379917364e2c27b51cce94" + integrity sha512-ZGflwlUANnEbsX/whWqRomyRHS36F1t5AoNBez2EfBVGXMIu7IsURVQfK/UJYPLoSHcArcTFCSbi5KSSsSiymw== dependencies: svg2vectordrawable "^2.9.1" "@vector-im/compound-web@^0.2.3": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-0.2.3.tgz#9dd4ed80109c614666103d05cd66723d1fad4d6c" - integrity sha512-7FI6Q1LN8dXur2sarP7UeMtAKcejuFw6AppM9Lu9fFjwLlbuIX2ZEprw1qa+EzgzUTysTU1TTdo7fxNqQwAQcA== + version "0.2.10" + resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-0.2.10.tgz#7178844159338ccaca63e0acce3a1ce94768f79c" + integrity sha512-dnD4ffbANPwWlApXxy5g3prcAD8WjGhtGbLMW5JiOruYwLNRFZ75XaTI22pYykfyl03VZeXapAfU/pNm/jZE1A== dependencies: "@radix-ui/react-form" "^0.0.3" classnames "^2.3.2" - lodash "^4.17.21" + graphemer "^1.4.0" + rimraf "^3.0.1" abab@^2.0.6: version "2.0.6" @@ -8694,7 +8695,7 @@ rfdc@^1.3.0: resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== -rimraf@^3.0.0, rimraf@^3.0.2: +rimraf@^3.0.0, rimraf@^3.0.1, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==