diff --git a/res/css/_components.scss b/res/css/_components.scss index e607deffe81..f7545fbe421 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -329,3 +329,4 @@ @import "./views/voip/_DialPadModal.scss"; @import "./views/voip/_PiPContainer.scss"; @import "./views/voip/_VideoFeed.scss"; +@import "./views/voip/_VoiceChannelRadio.scss"; diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index a6b2970ddf6..10dbfae0cef 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -212,6 +212,17 @@ hr.mx_RoomView_myReadMarker { opacity: 1; } +// Immersive widgets +.mx_RoomView_body > .mx_AppTile { + margin: $container-gap-width; + margin-right: calc($container-gap-width / 2); + width: auto; + height: 100%; + + background: none; + border: none; +} + .mx_RoomView_callStatusBar .mx_UploadBar_uploadProgressInner { background-color: $background; } diff --git a/res/css/views/elements/_FacePile.scss b/res/css/views/elements/_FacePile.scss index 875e0e34d57..68a2f72b112 100644 --- a/res/css/views/elements/_FacePile.scss +++ b/res/css/views/elements/_FacePile.scss @@ -20,7 +20,7 @@ limitations under the License. flex-direction: row-reverse; vertical-align: middle; - > .mx_FacePile_face + .mx_FacePile_face { + > * + * { margin-right: -8px; } diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index a172270de6e..3b7a51797f9 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -64,7 +64,7 @@ limitations under the License. margin-bottom: 8px; } - .mx_RoomTile_nameContainer { + .mx_RoomTile_titleContainer { width: 154px; } @@ -72,7 +72,7 @@ limitations under the License. display: none; } - .mx_RoomTile_name { + .mx_RoomTile_title { width: 160px; } diff --git a/res/css/views/rooms/_MemberInfo.scss b/res/css/views/rooms/_MemberInfo.scss index b0583ee94ec..1e7a777d546 100644 --- a/res/css/views/rooms/_MemberInfo.scss +++ b/res/css/views/rooms/_MemberInfo.scss @@ -58,7 +58,7 @@ limitations under the License. margin: 0 16px 16px 16px; } -.mx_MemberInfo .mx_RoomTile_nameContainer { +.mx_MemberInfo .mx_RoomTile_titleContainer { width: 154px; } @@ -66,7 +66,7 @@ limitations under the License. display: none; } -.mx_MemberInfo .mx_RoomTile_name { +.mx_MemberInfo .mx_RoomTile_title { width: 160px; } diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index 9253a25e327..ec6e5fe00aa 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -20,7 +20,6 @@ limitations under the License. padding: 4px; contain: content; // Not strict as it will break when resizing a sublist vertically - height: 40px; box-sizing: border-box; // The tile is also a flexbox row itself @@ -35,106 +34,166 @@ limitations under the License. } .mx_DecoratedRoomAvatar, .mx_RoomTile_avatarContainer { - margin-right: 8px; + margin-right: 10px; } - .mx_RoomTile_nameContainer { + .mx_RoomTile_details { flex-grow: 1; min-width: 0; // allow flex to shrink it - margin-right: 8px; // spacing to buttons/badges - - // Create a new column layout flexbox for the name parts display: flex; flex-direction: column; - justify-content: center; - - .mx_RoomTile_name, - .mx_RoomTile_messagePreview { - margin: 0 2px; - width: 100%; - - // Ellipsize any text overflow - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - - .mx_RoomTile_name { - font-size: $font-14px; - line-height: $font-18px; - } - - .mx_RoomTile_name.mx_RoomTile_nameHasUnreadEvents { - font-weight: 600; - } - .mx_RoomTile_messagePreview { - font-size: $font-13px; - line-height: $font-18px; - color: $secondary-content; - } + .mx_RoomTile_primaryDetails { + height: 32px; + display: flex; + flex-wrap: wrap; + + .mx_RoomTile_titleContainer { + min-width: 0; + flex-basis: 0; + flex-grow: 1; + margin-right: 8px; // spacing to buttons/badges + + // Create a new column layout flexbox for the title parts + display: flex; + flex-direction: column; + justify-content: center; + + .mx_RoomTile_title, .mx_RoomTile_subtitle { + width: 100%; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .mx_RoomTile_title { + font-size: $font-14px; + line-height: $font-18px; + } + + .mx_RoomTile_title.mx_RoomTile_titleHasUnreadEvents { + font-weight: 600; + } + + .mx_RoomTile_subtitle { + font-size: $font-13px; + line-height: $font-18px; + color: $secondary-content; + } + + .mx_RoomTile_subtitle.mx_RoomTile_voiceIndicator { + &::before { + display: inline-block; + vertical-align: text-bottom; + content: ''; + background-color: $secondary-content; + mask-image: url('$(res)/img/voip/voice-room.svg'); + mask-size: 16px; + width: 16px; + height: 16px; + margin-right: 4px; + } + + &.mx_RoomTile_voiceIndicator_active { + color: $accent; + + &::before { + background-color: $accent; + } + } + } + + .mx_RoomTile_titleWithSubtitle { + margin-top: -3px; // shift the title up a bit more + } + } - .mx_RoomTile_nameWithPreview { - margin-top: -4px; // shift the name up a bit more - } - } + .mx_RoomTile_notificationsButton { + margin-left: 4px; // spacing between buttons + } - .mx_RoomTile_notificationsButton { - margin-left: 4px; // spacing between buttons - } + .mx_RoomTile_badgeContainer { + height: 16px; + // don't set width so that it takes no space when there is no badge to show + margin: auto 0; // vertically align + + // Create a flexbox to make aligning dot badges easier + display: flex; + align-items: center; + + .mx_NotificationBadge { + margin-right: 2px; // centering + } + + .mx_NotificationBadge_dot { + // make the smaller dot occupy the same width for centering + margin-left: 5px; + margin-right: 7px; + } + } - .mx_RoomTile_badgeContainer { - height: 16px; - // don't set width so that it takes no space when there is no badge to show - margin: auto 0; // vertically align + // The context menu buttons are hidden by default + .mx_RoomTile_menuButton, + .mx_RoomTile_notificationsButton { + width: 20px; + min-width: 20px; // yay flex + height: 20px; + margin-top: auto; + margin-bottom: auto; + position: relative; + display: none; - // Create a flexbox to make aligning dot badges easier - display: flex; - align-items: center; + &::before { + top: 2px; + left: 2px; + content: ''; + width: 16px; + height: 16px; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $primary-content; + } + } - .mx_NotificationBadge { - margin-right: 2px; // centering - } + // If the room has an overriden notification setting then we always show the notifications menu button + .mx_RoomTile_notificationsButton.mx_RoomTile_notificationsButton_show { + display: block; + } - .mx_NotificationBadge_dot { - // make the smaller dot occupy the same width for centering - margin-left: 5px; - margin-right: 7px; + .mx_RoomTile_menuButton::before { + mask-image: url('$(res)/img/element-icons/context-menu.svg'); + } } - } - // The context menu buttons are hidden by default - .mx_RoomTile_menuButton, - .mx_RoomTile_notificationsButton { - width: 20px; - min-width: 20px; // yay flex - height: 20px; - margin-top: auto; - margin-bottom: auto; - position: relative; - display: none; - - &::before { - top: 2px; - left: 2px; - content: ''; - width: 16px; - height: 16px; - position: absolute; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background: $primary-content; - } - } + .mx_RoomTile_voiceChannel { + width: 100%; + display: flex; + align-items: center; - // If the room has an overriden notification setting then we always show the notifications menu button - .mx_RoomTile_notificationsButton.mx_RoomTile_notificationsButton_show { - display: block; - } + .mx_FacePile { + margin: 6px 0 4px; + } - .mx_RoomTile_menuButton::before { - mask-image: url('$(res)/img/element-icons/context-menu.svg'); + .mx_RoomTile_connectVoiceButton { + font-weight: 600; + padding-left: 10px; + padding-right: 10px; + + &::before { + content: ''; + background-color: $accent; + mask-image: url('$(res)/img/voip/voice-room.svg'); + mask-size: 16px; + width: 16px; + height: 16px; + margin-right: 4px; + } + } + } } &:not(.mx_RoomTile_minimized) { @@ -163,6 +222,10 @@ limitations under the License. .mx_DecoratedRoomAvatar, .mx_RoomTile_avatarContainer { margin-right: 0; } + + .mx_RoomTile_details { + display: none; + } } } diff --git a/res/css/views/voip/_VoiceChannelRadio.scss b/res/css/views/voip/_VoiceChannelRadio.scss new file mode 100644 index 00000000000..d67a5b312f7 --- /dev/null +++ b/res/css/views/voip/_VoiceChannelRadio.scss @@ -0,0 +1,121 @@ +/* +Copyright 2022 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. +*/ + +.mx_VoiceChannelRadio { + background-color: $system; + + > .mx_VoiceChannelRadio_statusBar { + display: flex; + padding: 12px 16px; + align-items: center; + gap: 12px; + + > .mx_VoiceChannelRadio_titleContainer { + flex-grow: 1; + + > .mx_VoiceChannelRadio_status { + font-size: $font-15px; + color: $accent; + + &::before { + content: ''; + display: inline-block; + margin-right: 4px; + width: 11px; + height: 11px; + background-color: $accent; + mask-image: url('$(res)/img/voip/signal-bars.svg'); + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + } + } + + > .mx_VoiceChannelRadio_name { + font-size: $font-13px; + color: $secondary-content; + } + } + + > .mx_VoiceChannelRadio_disconnectButton::before { + content: ''; + display: block; + width: 36px; + height: 36px; + background-color: $tertiary-content; + mask-image: url('$(res)/img/element-icons/call/hangup.svg'); + mask-position: center; + mask-size: 24px; + mask-repeat: no-repeat; + } + } + + > .mx_VoiceChannelRadio_controlBar { + display: flex; + border-top: 1px solid $quinary-content; + padding: 12px 16px; + align-items: center; + justify-content: space-between; + + > .mx_AccessibleButton { + font-size: $font-15px; + padding: 6px 0; + + &.mx_VoiceChannelRadio_button_active { + padding: 6px 12px; + background-color: $quinary-content; + border-radius: 8px; + font-weight: 600; + } + } + + > .mx_VoiceChannelRadio_videoButton::before { + content: ''; + display: inline-block; + margin-right: 8px; + width: 16px; + height: 16px; + background-color: $primary-content; + vertical-align: sub; + mask-image: url('$(res)/img/voip/call-view/cam-off.svg'); + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + } + + > .mx_VoiceChannelRadio_videoButton.mx_VoiceChannelRadio_button_active::before { + mask-image: url('$(res)/img/voip/call-view/cam-on.svg'); + } + + > .mx_VoiceChannelRadio_audioButton::before { + content: ''; + display: inline-block; + margin-right: 4px; + width: 16px; + height: 16px; + background-color: $primary-content; + vertical-align: sub; + mask-image: url('$(res)/img/voip/call-view/mic-off.svg'); + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + } + + > .mx_VoiceChannelRadio_audioButton.mx_VoiceChannelRadio_button_active::before { + mask-image: url('$(res)/img/voip/call-view/mic-on.svg'); + } + } +} diff --git a/res/img/voip/signal-bars.svg b/res/img/voip/signal-bars.svg new file mode 100644 index 00000000000..6802ba2d34b --- /dev/null +++ b/res/img/voip/signal-bars.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/voip/voice-room.svg b/res/img/voip/voice-room.svg new file mode 100644 index 00000000000..db62957ee33 --- /dev/null +++ b/res/img/voip/voice-room.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 2572c685b4d..dc2d60db0e7 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -18,7 +18,6 @@ limitations under the License. */ import React from 'react'; -import { base32 } from "rfc4648"; import { CallError, CallErrorCode, @@ -29,7 +28,6 @@ import { MatrixCall, } from "matrix-js-sdk/src/webrtc/call"; import { logger } from 'matrix-js-sdk/src/logger'; -import { randomLowercaseString, randomUppercaseString } from "matrix-js-sdk/src/randomstring"; import EventEmitter from 'events'; import { RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules"; import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; @@ -42,7 +40,6 @@ import { _t } from './languageHandler'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; import SettingsStore from './settings/SettingsStore'; -import { Jitsi } from "./widgets/Jitsi"; import { WidgetType } from "./widgets/WidgetType"; import { SettingLevel } from "./settings/SettingLevel"; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; @@ -1023,65 +1020,26 @@ export default class CallHandler extends EventEmitter { return false; } - private async placeJitsiCall(roomId: string, type: string): Promise { - logger.info("Place conference call in " + roomId); + private async placeJitsiCall(roomId: string, type: CallType): Promise { + const client = MatrixClientPeg.get(); + logger.info(`Place conference call in ${roomId}`); Analytics.trackEvent('voip', 'placeConferenceCall'); - dis.dispatch({ - action: 'appsDrawer', - show: true, - }); + dis.dispatch({ action: 'appsDrawer', show: true }); - // prevent double clicking the call button - const room = MatrixClientPeg.get().getRoom(roomId); - const jitsiWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.JITSI.matches(app.type)); - if (jitsiWidget) { - // If there already is a Jitsi widget pin it - WidgetLayoutStore.instance.moveToContainer(room, jitsiWidget, Container.Top); + // Prevent double clicking the call button + const widget = WidgetStore.instance.getApps(roomId).find(app => WidgetType.JITSI.matches(app.type)); + if (widget) { + // If there already is a Jitsi widget, pin it + WidgetLayoutStore.instance.moveToContainer(client.getRoom(roomId), widget, Container.Top); return; } - const jitsiDomain = Jitsi.getInstance().preferredDomain; - const jitsiAuth = await Jitsi.getInstance().getJitsiAuth(); - let confId; - if (jitsiAuth === 'openidtoken-jwt') { - // Create conference ID from room ID - // For compatibility with Jitsi, use base32 without padding. - // More details here: - // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification - confId = base32.stringify(Buffer.from(roomId), { pad: false }); - } else { - // Create a random conference ID - const random = randomUppercaseString(1) + randomLowercaseString(23); - confId = 'Jitsi' + random; - } - - let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({ auth: jitsiAuth }); - - // TODO: Remove URL hacks when the mobile clients eventually support v2 widgets - const parsedUrl = new URL(widgetUrl); - parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead - parsedUrl.searchParams.set('confId', confId); - widgetUrl = parsedUrl.toString(); - - const widgetData = { - conferenceId: confId, - isAudioOnly: type === 'voice', - domain: jitsiDomain, - auth: jitsiAuth, - roomName: room.name, - }; - - const widgetId = ( - 'jitsi_' + - MatrixClientPeg.get().credentials.userId + - '_' + - Date.now() - ); - - WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => { + try { + const userId = client.credentials.userId; + await WidgetUtils.addJitsiWidget(roomId, type, 'Jitsi', `jitsi_${userId}_${Date.now()}`); logger.log('Jitsi widget added'); - }).catch((e) => { + } catch (e) { if (e.errcode === 'M_FORBIDDEN') { Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { title: _t('Permission Required'), @@ -1089,7 +1047,7 @@ export default class CallHandler extends EventEmitter { }); } logger.error(e); - }); + } } public terminateCallApp(roomId: string): void { diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 08f0078c134..fa36a4736ca 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -41,6 +41,7 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore"; import IndicatorScrollbar from "./IndicatorScrollbar"; import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs"; import SettingsStore from "../../settings/SettingsStore"; +import VoiceChannelRadio from "../views/voip/VoiceChannelRadio"; import UserMenu from "./UserMenu"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { shouldShowComponent } from "../../customisations/helpers/UIComponents"; @@ -443,6 +444,7 @@ export default class LeftPanel extends React.Component { { roomList } + { SettingsStore.getValue("feature_voice_rooms") && } ); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index b6591bc2b7e..3b853f8d6d0 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -75,6 +75,8 @@ import EffectsOverlay from "../views/elements/EffectsOverlay"; import { containsEmoji } from '../../effects/utils'; import { CHAT_EFFECTS } from '../../effects'; import WidgetStore from "../../stores/WidgetStore"; +import { getVoiceChannel } from "../../utils/VoiceChannelUtils"; +import AppTile from "../views/elements/AppTile"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import Notifier from "../../Notifier"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; @@ -137,7 +139,7 @@ interface IRoomProps extends MatrixClientProps { enum MainSplitContentType { Timeline, MaximisedWidget, - // Video + Video, // immersive voip } export interface IRoomState { room?: Room; @@ -189,6 +191,7 @@ export interface IRoomState { canReact: boolean; canSendMessages: boolean; tombstone?: MatrixEvent; + resizing: boolean; layout: Layout; lowBandwidth: boolean; alwaysShowTimestamps: boolean; @@ -261,6 +264,7 @@ export class RoomView extends React.Component { statusBarVisible: false, canReact: false, canSendMessages: false, + resizing: false, layout: SettingsStore.getValue("layout"), lowBandwidth: SettingsStore.getValue("lowBandwidth"), alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"), @@ -302,6 +306,8 @@ export class RoomView extends React.Component { WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); + this.props.resizeNotifier.on("isResizing", this.onIsResizing); + this.settingWatchers = [ SettingsStore.watchSetting("layout", null, (...[,,, value]) => this.setState({ layout: value as Layout }), @@ -327,6 +333,10 @@ export class RoomView extends React.Component { ]; } + private onIsResizing = (resizing: boolean) => { + this.setState({ resizing }); + }; + private onWidgetStoreUpdate = () => { if (!this.state.room) return; this.checkWidgets(this.state.room); @@ -366,10 +376,13 @@ export class RoomView extends React.Component { }; private getMainSplitContentType = (room) => { - // TODO-video check if video should be displayed in main panel - return (WidgetLayoutStore.instance.hasMaximisedWidget(room)) - ? MainSplitContentType.MaximisedWidget - : MainSplitContentType.Timeline; + if (SettingsStore.getValue("feature_voice_rooms") && room.isCallRoom()) { + return MainSplitContentType.Video; + } + if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { + return MainSplitContentType.MaximisedWidget; + } + return MainSplitContentType.Timeline; }; private onRoomViewStoreUpdate = async (initial?: boolean): Promise => { @@ -729,6 +742,8 @@ export class RoomView extends React.Component { WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate); + this.props.resizeNotifier.off("isResizing", this.onIsResizing); + if (this.state.room) { WidgetLayoutStore.instance.off( WidgetLayoutStore.emissionForRoom(this.state.room), @@ -2089,28 +2104,27 @@ export class RoomView extends React.Component { const showChatEffects = SettingsStore.getValue('showChatEffects'); + let mainSplitBody; // Decide what to show in the main split - let mainSplitBody = - - { auxPanel } -
- - { topUnreadMessagesBar } - { jumpToBottom } - { messagePanel } - { searchResultsPanel } -
- { statusBarArea } - { previewBar } - { messageComposer } -
; - switch (this.state.mainSplitContentType) { case MainSplitContentType.Timeline: - // keep the timeline in as the mainSplitBody + mainSplitBody = <> + + { auxPanel } +
+ + { topUnreadMessagesBar } + { jumpToBottom } + { messagePanel } + { searchResultsPanel } +
+ { statusBarArea } + { previewBar } + { messageComposer } + ; break; case MainSplitContentType.MaximisedWidget: mainSplitBody = { showApps={true} />; break; - // TODO-video MainSplitContentType.Video: - // break; + case MainSplitContentType.Video: { + const app = getVoiceChannel(this.state.room.roomId); + if (!app) break; + mainSplitBody = ; + } } let excludedRightPanelPhaseButtons = [RightPanelPhases.Timeline]; let onAppsClick = this.onAppsClick; let onForgetClick = this.onForgetClick; let onSearchClick = this.onSearchClick; - if (this.state.mainSplitContentType === MainSplitContentType.MaximisedWidget) { - // Disable phase buttons and action button to have a simplified header when a widget is maximised + if (this.state.mainSplitContentType !== MainSplitContentType.Timeline) { + // Disable phase buttons and action button to have a simplified header // and enable (not disable) the RightPanelPhases.Timeline button excludedRightPanelPhaseButtons = [ RightPanelPhases.ThreadPanel, diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 42e2d3adf23..467d47c92c8 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -58,7 +58,7 @@ import { import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; import MemberAvatar from "../views/avatars/MemberAvatar"; import SpaceStore from "../../stores/spaces/SpaceStore"; -import FacePile from "../views/elements/FacePile"; +import { RoomFacePile } from "../views/elements/FacePile"; import { AddExistingToSpace, defaultDmsRenderer, @@ -354,7 +354,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISp } - { space.getJoinRule() === "public" && } + { space.getJoinRule() === "public" && }
{ joinButtons }
@@ -495,7 +495,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
- + { inviteButton } { settingsButton }
diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index 254e8ba0dc1..2992a033459 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -17,16 +17,20 @@ limitations under the License. import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomType } from "matrix-js-sdk/src/@types/event"; import { JoinRule, Preset, Visibility } from "matrix-js-sdk/src/@types/partials"; import SdkConfig from '../../../SdkConfig'; +import SettingsStore from "../../../settings/SettingsStore"; import withValidation, { IFieldState } from '../elements/Validation'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { IOpts, privateShouldBeEncrypted } from "../../../createRoom"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Heading from "../typography/Heading"; import Field from "../elements/Field"; +import StyledRadioGroup from "../elements/StyledRadioGroup"; import RoomAliasField from "../elements/RoomAliasField"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import DialogButtons from "../elements/DialogButtons"; @@ -45,6 +49,7 @@ interface IProps { } interface IState { + type?: RoomType; joinRule: JoinRule; isPublic: boolean; isEncrypted: boolean; @@ -76,6 +81,7 @@ export default class CreateRoomDialog extends React.Component { } this.state = { + type: null, isPublic: this.props.defaultPublic || false, isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(), joinRule, @@ -95,6 +101,7 @@ export default class CreateRoomDialog extends React.Component { private roomCreateOptions() { const opts: IOpts = {}; const createOpts: IOpts["createOpts"] = opts.createOpts = {}; + opts.roomType = this.state.type; createOpts.name = this.state.name; if (this.state.joinRule === JoinRule.Public) { @@ -178,6 +185,10 @@ export default class CreateRoomDialog extends React.Component { this.props.onFinished(false); }; + private onTypeChange = (type: RoomType | "text") => { + this.setState({ type: type === "text" ? null : type }); + }; + private onNameChange = (ev: ChangeEvent) => { this.setState({ name: ev.target.value }); }; @@ -337,6 +348,20 @@ export default class CreateRoomDialog extends React.Component { >
+ { SettingsStore.getValue("feature_voice_rooms") ? <> + { _t("Room type") } + + + { _t("Room details") } + : null } { + faces: ReactNode[]; + overflow: boolean; + tooltip?: ReactNode; + children?: ReactNode; +} + +const FacePile = ({ faces, overflow, tooltip, children, ...props }: IProps) => { + const pileContents = <> + { overflow ? : null } + { faces } + ; + + return
+ { tooltip ? ( + + { pileContents } + + ) : ( +
+ { pileContents } +
+ ) } + { children } +
; +}; + +export default FacePile; + const DEFAULT_NUM_FACES = 5; -interface IProps extends HTMLAttributes { +const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length; + +interface IRoomProps extends HTMLAttributes { room: Room; onlyKnownUsers?: boolean; numShown?: number; } -const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length; - -const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IProps) => { +export const RoomFacePile = ( + { room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IRoomProps, +) => { const cli = useContext(MatrixClientContext); const isJoined = room.getMyMembership() === "join"; let members = useRoomMembers(room); @@ -58,6 +89,8 @@ const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, . // We reverse the order of the shown faces in CSS to simplify their visual overlap, // reverse members in tooltip order to make the order between the two match up. const commaSeparatedMembers = shownMembers.map(m => m.rawDisplayName).reverse().join(", "); + const faces = shownMembers.map(m => + ); let tooltip: ReactNode; if (props.onClick) { @@ -90,16 +123,9 @@ const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, . } } - return
- - { members.length > numShown ? : null } - { shownMembers.map(m => - ) } - + return numShown} tooltip={tooltip}> { onlyKnownUsers && { _t("%(count)s people you know have already joined", { count: members.length }) } } -
; + ; }; - -export default FacePile; diff --git a/src/components/views/groups/GroupInviteTile.js b/src/components/views/groups/GroupInviteTile.js index 3139187709f..0c3e8c7f4b0 100644 --- a/src/components/views/groups/GroupInviteTile.js +++ b/src/components/views/groups/GroupInviteTile.js @@ -126,7 +126,7 @@ export default class GroupInviteTile extends React.Component { const av = ; const isMenuDisplayed = Boolean(this.state.contextMenuPosition); - const nameClasses = classNames('mx_RoomTile_name mx_RoomTile_invite mx_RoomTile_badgeShown', { + const nameClasses = classNames('mx_RoomTile_title mx_RoomTile_invite mx_RoomTile_badgeShown', { 'mx_RoomTile_badgeShown': this.state.badgeHover || isMenuDisplayed, }); @@ -180,17 +180,21 @@ export default class GroupInviteTile extends React.Component {
{ av }
-
- { label } - - { badgeContent } - +
+
+
+ { label } + + { badgeContent } + +
+
{ tooltip } diff --git a/src/components/views/rooms/ExtraTile.tsx b/src/components/views/rooms/ExtraTile.tsx index 47862d71a59..313ae28b1f8 100644 --- a/src/components/views/rooms/ExtraTile.tsx +++ b/src/components/views/rooms/ExtraTile.tsx @@ -79,12 +79,12 @@ export default class ExtraTile extends React.Component { name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon const nameClasses = classNames({ - "mx_RoomTile_name": true, - "mx_RoomTile_nameHasUnreadEvents": this.props.notificationState?.isUnread, + "mx_RoomTile_title": true, + "mx_RoomTile_titleHasUnreadEvents": this.props.notificationState?.isUnread, }); let nameContainer = ( -
+
{ name }
@@ -110,9 +110,13 @@ export default class ExtraTile extends React.Component {
{ this.props.avatar }
- { nameContainer } -
- { badge } +
+
+ { nameContainer } +
+ { badge } +
+
diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index dababf8e75b..35101410439 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -25,12 +25,15 @@ import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleBu import dis from '../../../dispatcher/dispatcher'; import defaultDispatcher from '../../../dispatcher/dispatcher'; import { Action } from "../../../dispatcher/actions"; +import SettingsStore from "../../../settings/SettingsStore"; import ActiveRoomObserver from "../../../ActiveRoomObserver"; import { _t } from "../../../languageHandler"; import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextMenu"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; +import BaseAvatar from "../avatars/BaseAvatar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; +import FacePile from "../elements/FacePile"; import { RoomNotifState } from "../../../RoomNotifs"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import NotificationBadge from "./NotificationBadge"; @@ -50,12 +53,19 @@ import IconizedContextMenu, { IconizedContextMenuRadio, } from "../context_menus/IconizedContextMenu"; import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore"; +import VoiceChannelStore, { VoiceChannelEvent, IJitsiParticipant } from "../../../stores/VoiceChannelStore"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +enum VoiceConnectionState { + Disconnected, + Connecting, + Connected, +} + interface IProps { room: Room; showMessagePreview: boolean; @@ -70,6 +80,8 @@ interface IState { notificationsMenuPosition: PartialDOMRect; generalMenuPosition: PartialDOMRect; messagePreview?: string; + voiceConnectionState: VoiceConnectionState; + voiceParticipants: IJitsiParticipant[]; } const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`; @@ -88,6 +100,7 @@ export default class RoomTile extends React.PureComponent { private roomTileRef = createRef(); private notificationState: NotificationState; private roomProps: RoomEchoChamber; + private isVoiceRoom: boolean; constructor(props: IProps) { super(props); @@ -96,14 +109,17 @@ export default class RoomTile extends React.PureComponent { selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, notificationsMenuPosition: null, generalMenuPosition: null, - // generatePreview() will return nothing if the user has previews disabled messagePreview: "", + voiceConnectionState: VoiceChannelStore.instance.roomId === this.props.room.roomId ? + VoiceConnectionState.Connected : VoiceConnectionState.Disconnected, + voiceParticipants: [], }; this.generatePreview(); this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room); this.roomProps = EchoChamber.forRoom(this.props.room); + this.isVoiceRoom = SettingsStore.getValue("feature_voice_rooms") && this.props.room.isCallRoom(); } private onRoomNameUpdate = (room: Room) => { @@ -238,7 +254,7 @@ export default class RoomTile extends React.PureComponent { }); }; - private onTileClick = (ev: React.KeyboardEvent) => { + private onTileClick = async (ev: React.KeyboardEvent) => { ev.preventDefault(); ev.stopPropagation(); @@ -252,6 +268,11 @@ export default class RoomTile extends React.PureComponent { metricsTrigger: "RoomList", metricsViaKeyboard: ev.type !== "click", }); + + // Connect to the voice channel if this is a voice room + if (this.isVoiceRoom && this.state.voiceConnectionState === VoiceConnectionState.Disconnected) { + await this.connectVoice(); + } }; private onActiveRoomUpdate = (isActive: boolean) => { @@ -576,6 +597,68 @@ export default class RoomTile extends React.PureComponent { ); } + private updateVoiceParticipants = (participants: IJitsiParticipant[]) => { + this.setState({ voiceParticipants: participants }); + }; + + private renderVoiceChannel(): React.ReactElement { + if (!this.state.voiceParticipants.length) return null; + + const faces = this.state.voiceParticipants.map(p => + , + ); + + // TODO: The below "join" button will eventually show up on text rooms + // with an active voice channel, but that isn't implemented yet + return
+ + { this.isVoiceRoom ? null : ( + + { _t("Join") } + + ) } +
; + } + + private async connectVoice() { + this.setState({ voiceConnectionState: VoiceConnectionState.Connecting }); + // TODO: Actually wait for the widget to be ready, instead of guessing. + // This hack is only in place until we find out for sure whether design + // wants the room view to open when connecting voice, or if this should + // somehow connect in the background. Until then, it's not worth the + // effort to solve this properly. + await new Promise(resolve => setTimeout(resolve, 1000)); + + try { + await VoiceChannelStore.instance.connect(this.props.room.roomId); + + this.setState({ voiceConnectionState: VoiceConnectionState.Connected }); + VoiceChannelStore.instance.once(VoiceChannelEvent.Disconnect, () => { + this.setState({ + voiceConnectionState: VoiceConnectionState.Disconnected, + voiceParticipants: [], + }), + VoiceChannelStore.instance.off(VoiceChannelEvent.Participants, this.updateVoiceParticipants); + }); + VoiceChannelStore.instance.on(VoiceChannelEvent.Participants, this.updateVoiceParticipants); + } catch (e) { + logger.error("Failed to connect voice", e); + this.setState({ voiceConnectionState: VoiceConnectionState.Disconnected }); + } + } + public render(): React.ReactElement { const classes = classNames({ 'mx_RoomTile': true, @@ -607,11 +690,39 @@ export default class RoomTile extends React.PureComponent { ); } - let messagePreview = null; - if (this.showMessagePreview && this.state.messagePreview) { - messagePreview = ( + let subtitle; + if (this.isVoiceRoom) { + switch (this.state.voiceConnectionState) { + case VoiceConnectionState.Disconnected: + subtitle = ( +
+ { _t("Voice room") } +
+ ); + break; + case VoiceConnectionState.Connecting: + subtitle = ( +
+ { _t("Connecting...") } +
+ ); + break; + case VoiceConnectionState.Connected: + subtitle = ( +
+ { _t("Connected") } +
+ ); + } + } else if (this.showMessagePreview && this.state.messagePreview) { + subtitle = (
@@ -620,21 +731,20 @@ export default class RoomTile extends React.PureComponent { ); } - const nameClasses = classNames({ - "mx_RoomTile_name": true, - "mx_RoomTile_nameWithPreview": !!messagePreview, - "mx_RoomTile_nameHasUnreadEvents": this.notificationState.isUnread, + const titleClasses = classNames({ + "mx_RoomTile_title": true, + "mx_RoomTile_titleWithSubtitle": !!subtitle, + "mx_RoomTile_titleHasUnreadEvents": this.notificationState.isUnread, }); - let nameContainer = ( -
-
+ const titleContainer = this.props.isMinimized ? null : ( +
+
{ name }
- { messagePreview } + { subtitle }
); - if (this.props.isMinimized) nameContainer = null; let ariaLabel = name; // The following labels are written in such a fashion to increase screen reader efficiency (speed). @@ -690,10 +800,15 @@ export default class RoomTile extends React.PureComponent { oobData={({ avatarUrl: roomProfile.avatarMxc })} tooltipProps={{ tabIndex: isActive ? 0 : -1 }} /> - { nameContainer } - { badge } - { this.renderGeneralMenu() } - { this.renderNotificationsMenu(isActive) } +
+
+ { titleContainer } + { badge } + { this.renderGeneralMenu() } + { this.renderNotificationsMenu(isActive) } +
+ { this.renderVoiceChannel() } +
} diff --git a/src/components/views/voip/VoiceChannelRadio.tsx b/src/components/views/voip/VoiceChannelRadio.tsx new file mode 100644 index 00000000000..3a3e362e5bb --- /dev/null +++ b/src/components/views/voip/VoiceChannelRadio.tsx @@ -0,0 +1,91 @@ +/* +Copyright 2022 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 React, { FC, useState, useContext } from "react"; +import classNames from "classnames"; + +import { _t } from "../../../languageHandler"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import VoiceChannelStore, { VoiceChannelEvent } from "../../../stores/VoiceChannelStore"; +import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; +import AccessibleButton from "../elements/AccessibleButton"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; + +const _VoiceChannelRadio: FC<{ roomId: string }> = ({ roomId }) => { + const cli = useContext(MatrixClientContext); + const room = cli.getRoom(roomId); + const store = VoiceChannelStore.instance; + + const [audioMuted, setAudioMuted] = useState(store.audioMuted); + const [videoMuted, setVideoMuted] = useState(store.videoMuted); + + useEventEmitter(store, VoiceChannelEvent.MuteAudio, () => setAudioMuted(true)); + useEventEmitter(store, VoiceChannelEvent.UnmuteAudio, () => setAudioMuted(false)); + useEventEmitter(store, VoiceChannelEvent.MuteVideo, () => setVideoMuted(true)); + useEventEmitter(store, VoiceChannelEvent.UnmuteVideo, () => setVideoMuted(false)); + + return
+
+ +
+
{ _t("Connected") }
+
{ room.name }
+
+ store.disconnect()} + /> +
+
+ videoMuted ? store.unmuteVideo() : store.muteVideo()} + > + { videoMuted ? _t("Video off") : _t("Video") } + + audioMuted ? store.unmuteAudio() : store.muteAudio()} + > + { audioMuted ? _t("Mic off") : _t("Mic") } + +
+
; +}; + +const VoiceChannelRadio: FC<{}> = () => { + const store = VoiceChannelStore.instance; + + const [activeChannel, setActiveChannel] = useState(VoiceChannelStore.instance.roomId); + useEventEmitter(store, VoiceChannelEvent.Connect, () => + setActiveChannel(VoiceChannelStore.instance.roomId), + ); + useEventEmitter(store, VoiceChannelEvent.Disconnect, () => + setActiveChannel(null), + ); + + return activeChannel ? <_VoiceChannelRadio roomId={activeChannel} /> : null; +}; + +export default VoiceChannelRadio; diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 7665c656dd1..631b849e013 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -47,6 +47,7 @@ const RoomContext = createContext({ statusBarVisible: false, canReact: false, canSendMessages: false, + resizing: false, layout: Layout.Group, lowBandwidth: false, alwaysShowTimestamps: false, diff --git a/src/createRoom.ts b/src/createRoom.ts index 2677075652d..0523e08b0df 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -43,6 +43,7 @@ import { isJoinedOrNearlyJoined } from "./utils/membership"; import { VIRTUAL_ROOM_EVENT_TYPE } from "./CallHandler"; import SpaceStore from "./stores/spaces/SpaceStore"; import { makeSpaceParentEvent } from "./utils/space"; +import { addVoiceChannel } from "./utils/VoiceChannelUtils"; import { Action } from "./dispatcher/actions"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import Spinner from "./components/views/elements/Spinner"; @@ -247,6 +248,11 @@ export default async function createRoom(opts: IOpts): Promise { if (opts.associatedWithCommunity) { return GroupStore.addRoomToGroup(opts.associatedWithCommunity, roomId, false); } + }).then(() => { + // Set up voice rooms with a Jitsi widget + if (opts.roomType === RoomType.UnstableCall) { + return addVoiceChannel(roomId, createOpts.name); + } }).then(function() { // NB createRoom doesn't block on the client seeing the echo that the // room has been created, so we race here with the client knowing that diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ddda953227f..e5800fd3d49 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -880,6 +880,7 @@ "Threaded messaging": "Threaded messaging", "Custom user status messages": "Custom user status messages", "Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)", + "Voice & video rooms (under active development)": "Voice & video rooms (under active development)", "Render simple counters in room header": "Render simple counters in room header", "Multiple integration managers (requires manual setup)": "Multiple integration managers (requires manual setup)", "Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)", @@ -1017,6 +1018,12 @@ "Your camera is turned off": "Your camera is turned off", "Your camera is still enabled": "Your camera is still enabled", "Dial": "Dial", + "Connected": "Connected", + "Disconnect": "Disconnect", + "Video off": "Video off", + "Video": "Video", + "Mic off": "Mic off", + "Mic": "Mic", "Dialpad": "Dialpad", "Mute the microphone": "Mute the microphone", "Unmute the microphone": "Unmute the microphone", @@ -1368,7 +1375,6 @@ "The identity server you have chosen does not have any terms of service.": "The identity server you have chosen does not have any terms of service.", "Disconnect identity server": "Disconnect identity server", "Disconnect from the identity server ?": "Disconnect from the identity server ?", - "Disconnect": "Disconnect", "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.": "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.", "You should:": "You should:", "check your browser plugins for anything that might block the identity server (such as Privacy Badger)": "check your browser plugins for anything that might block the identity server (such as Privacy Badger)", @@ -1866,6 +1872,9 @@ "Low Priority": "Low Priority", "Copy room link": "Copy room link", "Leave": "Leave", + "Join": "Join", + "Voice room": "Voice room", + "Connecting...": "Connecting...", "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", "%(count)s unread messages including mentions.|one": "1 unread mention.", "%(count)s unread messages.|other": "%(count)s unread messages.", @@ -2249,7 +2258,6 @@ "Application window": "Application window", "Share content": "Share content", "Backspace": "Backspace", - "Join": "Join", "Please create a new issue on GitHub so that we can investigate this bug.": "Please create a new issue on GitHub so that we can investigate this bug.", "%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s", "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times", @@ -2500,6 +2508,10 @@ "Create a room in %(communityName)s": "Create a room in %(communityName)s", "Create a public room": "Create a public room", "Create a private room": "Create a private room", + "Room type": "Room type", + "Text room": "Text room", + "Voice & video room": "Voice & video room", + "Room details": "Room details", "Topic (optional)": "Topic (optional)", "Room visibility": "Room visibility", "Private room (invite only)": "Private room (invite only)", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index fc4c4a68ced..f3cf3d281a9 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -256,6 +256,15 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: false, controller: new IncompatibleController("showCommunitiesInsteadOfSpaces", false, false), }, + "feature_voice_rooms": { + isFeature: true, + labsGroup: LabGroup.Rooms, + displayName: _td("Voice & video rooms (under active development)"), + supportedLevels: LEVELS_FEATURE, + default: false, + // Reload to ensure that the left panel etc. get remounted + controller: new ReloadOnChangeController(), + }, "feature_state_counters": { isFeature: true, labsGroup: LabGroup.Rooms, diff --git a/src/stores/VoiceChannelStore.ts b/src/stores/VoiceChannelStore.ts new file mode 100644 index 00000000000..d505c2575fa --- /dev/null +++ b/src/stores/VoiceChannelStore.ts @@ -0,0 +1,232 @@ +/* +Copyright 2022 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 { EventEmitter } from "events"; +import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api"; + +import { ElementWidgetActions } from "./widgets/ElementWidgetActions"; +import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore"; +import { getVoiceChannel } from "../utils/VoiceChannelUtils"; +import { timeout } from "../utils/promise"; +import WidgetUtils from "../utils/WidgetUtils"; + +export enum VoiceChannelEvent { + Connect = "connect", + Disconnect = "disconnect", + Participants = "participants", + MuteAudio = "mute_audio", + UnmuteAudio = "unmute_audio", + MuteVideo = "mute_video", + UnmuteVideo = "unmute_video", +} + +export interface IJitsiParticipant { + avatarURL: string; + displayName: string; + formattedDisplayName: string; + participantId: string; +} + +/* + * Holds information about the currently active voice channel. + */ +export default class VoiceChannelStore extends EventEmitter { + private static _instance: VoiceChannelStore; + private static readonly TIMEOUT = 8000; + + public static get instance(): VoiceChannelStore { + if (!VoiceChannelStore._instance) { + VoiceChannelStore._instance = new VoiceChannelStore(); + } + return VoiceChannelStore._instance; + } + + private activeChannel: ClientWidgetApi; + private _roomId: string; + private _participants: IJitsiParticipant[]; + private _audioMuted: boolean; + private _videoMuted: boolean; + + public get roomId(): string { + return this._roomId; + } + + public get participants(): IJitsiParticipant[] { + return this._participants; + } + + public get audioMuted(): boolean { + return this._audioMuted; + } + + public get videoMuted(): boolean { + return this._videoMuted; + } + + public connect = async (roomId: string) => { + if (this.activeChannel) await this.disconnect(); + + const jitsi = getVoiceChannel(roomId); + if (!jitsi) throw new Error(`No voice channel in room ${roomId}`); + + const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(jitsi)); + if (!messaging) throw new Error(`Failed to bind voice channel in room ${roomId}`); + + this.activeChannel = messaging; + this._roomId = roomId; + + // Participant data and mute state will come down the event pipeline very quickly, + // so prepare in advance + messaging.on(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); + messaging.on(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio); + messaging.on(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio); + messaging.on(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo); + messaging.on(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo); + + // Actually perform the join + const waitForJoin = this.waitForAction(ElementWidgetActions.JoinCall); + messaging.transport.send(ElementWidgetActions.JoinCall, {}); + try { + await waitForJoin; + } catch (e) { + // If it timed out, clean up our advance preparations + this.activeChannel = null; + this._roomId = null; + + messaging.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); + messaging.off(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio); + messaging.off(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio); + messaging.off(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo); + messaging.off(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo); + + throw e; + } + + messaging.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + + this.emit(VoiceChannelEvent.Connect); + }; + + public disconnect = async () => { + this.assertConnected(); + + const waitForHangup = this.waitForAction(ElementWidgetActions.HangupCall); + this.activeChannel.transport.send(ElementWidgetActions.HangupCall, {}); + await waitForHangup; + + // onHangup cleans up for us + }; + + public muteAudio = async () => { + this.assertConnected(); + + const waitForMute = this.waitForAction(ElementWidgetActions.MuteAudio); + this.activeChannel.transport.send(ElementWidgetActions.MuteAudio, {}); + await waitForMute; + }; + + public unmuteAudio = async () => { + this.assertConnected(); + + const waitForUnmute = this.waitForAction(ElementWidgetActions.UnmuteAudio); + this.activeChannel.transport.send(ElementWidgetActions.UnmuteAudio, {}); + await waitForUnmute; + }; + + public muteVideo = async () => { + this.assertConnected(); + + const waitForMute = this.waitForAction(ElementWidgetActions.MuteVideo); + this.activeChannel.transport.send(ElementWidgetActions.MuteVideo, {}); + await waitForMute; + }; + + public unmuteVideo = async () => { + this.assertConnected(); + + const waitForUnmute = this.waitForAction(ElementWidgetActions.UnmuteVideo); + this.activeChannel.transport.send(ElementWidgetActions.UnmuteVideo, {}); + await waitForUnmute; + }; + + private assertConnected = () => { + if (!this.activeChannel) throw new Error("Not connected to any voice channel"); + }; + + private waitForAction = async (action: ElementWidgetActions) => { + const wait = new Promise(resolve => + this.activeChannel.once(`action:${action}`, (ev: CustomEvent) => { + resolve(); + this.ack(ev); + }), + ); + if (await timeout(wait, false, VoiceChannelStore.TIMEOUT) === false) { + throw new Error("Communication with voice channel timed out"); + } + }; + + private ack = (ev: CustomEvent) => { + this.activeChannel.transport.reply(ev.detail, {}); + }; + + private onHangup = (ev: CustomEvent) => { + this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); + this.activeChannel.off(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio); + this.activeChannel.off(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio); + this.activeChannel.off(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo); + this.activeChannel.off(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo); + + this._roomId = null; + this._participants = null; + this._audioMuted = null; + this._videoMuted = null; + + this.emit(VoiceChannelEvent.Disconnect); + this.ack(ev); + // Save this for last, since ack needs activeChannel to exist + this.activeChannel = null; + }; + + private onParticipants = (ev: CustomEvent) => { + this._participants = ev.detail.data.participants as IJitsiParticipant[]; + this.emit(VoiceChannelEvent.Participants, ev.detail.data.participants); + this.ack(ev); + }; + + private onMuteAudio = (ev: CustomEvent) => { + this._audioMuted = true; + this.emit(VoiceChannelEvent.MuteAudio); + this.ack(ev); + }; + + private onUnmuteAudio = (ev: CustomEvent) => { + this._audioMuted = false; + this.emit(VoiceChannelEvent.UnmuteAudio); + this.ack(ev); + }; + + private onMuteVideo = (ev: CustomEvent) => { + this._videoMuted = true; + this.emit(VoiceChannelEvent.MuteVideo); + this.ack(ev); + }; + + private onUnmuteVideo = (ev: CustomEvent) => { + this._videoMuted = false; + this.emit(VoiceChannelEvent.UnmuteVideo); + this.ack(ev); + }; +} diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts index cd591a6fb43..e58581ce92a 100644 --- a/src/stores/widgets/ElementWidgetActions.ts +++ b/src/stores/widgets/ElementWidgetActions.ts @@ -18,7 +18,13 @@ import { IWidgetApiRequest } from "matrix-widget-api"; export enum ElementWidgetActions { ClientReady = "im.vector.ready", + JoinCall = "io.element.join", HangupCall = "im.vector.hangup", + CallParticipants = "io.element.participants", + MuteAudio = "io.element.mute_audio", + UnmuteAudio = "io.element.unmute_audio", + MuteVideo = "io.element.mute_video", + UnmuteVideo = "io.element.unmute_video", StartLiveStream = "im.vector.start_live_stream", OpenIntegrationManager = "integration_manager_open", diff --git a/src/utils/VoiceChannelUtils.ts b/src/utils/VoiceChannelUtils.ts new file mode 100644 index 00000000000..2b1deebddcf --- /dev/null +++ b/src/utils/VoiceChannelUtils.ts @@ -0,0 +1,32 @@ +/* +Copyright 2022 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 { CallType } from "matrix-js-sdk/src/webrtc/call"; + +import WidgetStore, { IApp } from "../stores/WidgetStore"; +import { WidgetType } from "../widgets/WidgetType"; +import WidgetUtils from "./WidgetUtils"; + +export const VOICE_CHANNEL_ID = "io.element.voice"; + +export const getVoiceChannel = (roomId: string): IApp => { + const apps = WidgetStore.instance.getApps(roomId); + return apps.find(app => WidgetType.JITSI.matches(app.type) && app.id === VOICE_CHANNEL_ID); +}; + +export const addVoiceChannel = async (roomId: string, roomName: string) => { + await WidgetUtils.addJitsiWidget(roomId, CallType.Voice, "Voice channel", VOICE_CHANNEL_ID, roomName); +}; diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts index 02bd980f038..1515b77b2c2 100644 --- a/src/utils/WidgetUtils.ts +++ b/src/utils/WidgetUtils.ts @@ -16,11 +16,14 @@ limitations under the License. */ import * as url from "url"; +import { base32 } from "rfc4648"; import { Capability, IWidget, IWidgetData, MatrixCapabilities } from "matrix-widget-api"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { logger } from "matrix-js-sdk/src/logger"; import { ClientEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; +import { randomLowercaseString, randomUppercaseString } from "matrix-js-sdk/src/randomstring"; import { MatrixClientPeg } from '../MatrixClientPeg'; import SdkConfig from "../SdkConfig"; @@ -29,6 +32,7 @@ import WidgetEchoStore from '../stores/WidgetEchoStore'; import SettingsStore from "../settings/SettingsStore"; import { IntegrationManagers } from "../integrations/IntegrationManagers"; import { WidgetType } from "../widgets/WidgetType"; +import { Jitsi } from "../widgets/Jitsi"; import { objectClone } from "./objects"; import { _t } from "../languageHandler"; import { IApp } from "../stores/WidgetStore"; @@ -434,6 +438,42 @@ export default class WidgetUtils { await client.setAccountData('m.widgets', userWidgets); } + static async addJitsiWidget( + roomId: string, + type: CallType, + name: string, + widgetId: string, + oobRoomName?: string, + ): Promise { + const domain = Jitsi.getInstance().preferredDomain; + const auth = await Jitsi.getInstance().getJitsiAuth(); + + let confId; + if (auth === 'openidtoken-jwt') { + // Create conference ID from room ID + // For compatibility with Jitsi, use base32 without padding. + // More details here: + // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification + confId = base32.stringify(Buffer.from(roomId), { pad: false }); + } else { + // Create a random conference ID + confId = `Jitsi${randomUppercaseString(1)}${randomLowercaseString(23)}`; + } + + // TODO: Remove URL hacks when the mobile clients eventually support v2 widgets + const widgetUrl = new URL(WidgetUtils.getLocalJitsiWrapperUrl({ auth })); + widgetUrl.search = ''; // Causes the URL class use searchParams instead + widgetUrl.searchParams.set('confId', confId); + + await WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl.toString(), name, { + conferenceId: confId, + roomName: oobRoomName ?? MatrixClientPeg.get().getRoom(roomId)?.name, + isAudioOnly: type === CallType.Voice, + domain, + auth, + }); + } + static makeAppConfig( appId: string, app: Partial, diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx new file mode 100644 index 00000000000..31f927c2d34 --- /dev/null +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -0,0 +1,130 @@ +/* +Copyright 2022 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 React from "react"; +import { mount } from "enzyme"; +import { act } from "react-dom/test-utils"; +import { MatrixWidgetType } from "matrix-widget-api"; + +import "../../../skinned-sdk"; +import { stubClient, mkStubRoom } from "../../../test-utils"; +import PlatformPeg from "../../../../src/PlatformPeg"; +import RoomTile from "../../../../src/components/views/rooms/RoomTile"; +import SettingsStore from "../../../../src/settings/SettingsStore"; +import WidgetStore from "../../../../src/stores/WidgetStore"; +import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; +import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions"; +import VoiceChannelStore, { VoiceChannelEvent } from "../../../../src/stores/VoiceChannelStore"; +import { DefaultTagID } from "../../../../src/stores/room-list/models"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; +import { VOICE_CHANNEL_ID } from "../../../../src/utils/VoiceChannelUtils"; + +describe("RoomTile", () => { + PlatformPeg.get = () => ({ overrideBrowserShortcuts: () => false }); + SettingsStore.getValue = setting => setting === "feature_voice_rooms"; + beforeEach(() => { + stubClient(); + DMRoomMap.makeShared(); + }); + + describe("voice rooms", () => { + const room = mkStubRoom("!1:example.org"); + room.isCallRoom.mockReturnValue(true); + + // Set up mocks to simulate the remote end of the widget API + let messageSent; + let messageSendMock; + let onceMock; + beforeEach(() => { + let resolveMessageSent; + messageSent = new Promise(resolve => resolveMessageSent = resolve); + messageSendMock = jest.fn().mockImplementation(() => resolveMessageSent()); + onceMock = jest.fn(); + + jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{ + id: VOICE_CHANNEL_ID, + eventId: "$1:example.org", + roomId: "!1:example.org", + type: MatrixWidgetType.JitsiMeet, + url: "", + name: "Voice channel", + creatorUserId: "@alice:example.org", + avatar_url: null, + }]); + jest.spyOn(WidgetMessagingStore.instance, "getMessagingForUid").mockReturnValue({ + on: () => {}, + off: () => {}, + once: onceMock, + transport: { + send: messageSendMock, + reply: () => {}, + }, + }); + }); + + it("tracks connection state", async () => { + const tile = mount( + , + ); + expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Voice room"); + + act(() => { tile.simulate("click"); }); + tile.update(); + expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Connecting..."); + + // Wait for the VoiceChannelStore to connect to the widget API + await messageSent; + // Then, locate the callback that will confirm the join + const [, join] = onceMock.mock.calls.find(([action]) => + action === `action:${ElementWidgetActions.JoinCall}`, + ); + + // Now we confirm the join and wait for the VoiceChannelStore to update + const waitForConnect = new Promise(resolve => + VoiceChannelStore.instance.once(VoiceChannelEvent.Connect, resolve), + ); + join({ detail: {} }); + await waitForConnect; + // Wait yet another tick for the room tile to update + await Promise.resolve(); + + tile.update(); + expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Connected"); + + // Locate the callback that will perform the hangup + const [, hangup] = onceMock.mock.calls.find(([action]) => + action === `action:${ElementWidgetActions.HangupCall}`, + ); + + // Hangup and wait for the VoiceChannelStore, once again + const waitForHangup = new Promise(resolve => + VoiceChannelStore.instance.once(VoiceChannelEvent.Disconnect, resolve), + ); + hangup({ detail: {} }); + await waitForHangup; + // Wait yet another tick for the room tile to update + await Promise.resolve(); + + tile.update(); + expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Voice room"); + }); + }); +}); diff --git a/test/components/views/voip/VoiceChannelRadio-test.tsx b/test/components/views/voip/VoiceChannelRadio-test.tsx new file mode 100644 index 00000000000..c02545ab70f --- /dev/null +++ b/test/components/views/voip/VoiceChannelRadio-test.tsx @@ -0,0 +1,141 @@ +/* +Copyright 2022 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 { EventEmitter } from "events"; +import React from "react"; +import { mount } from "enzyme"; +import { act } from "react-dom/test-utils"; + +import "../../../skinned-sdk"; +import { stubClient, mkStubRoom, wrapInMatrixClientContext } from "../../../test-utils"; +import _VoiceChannelRadio from "../../../../src/components/views/voip/VoiceChannelRadio"; +import VoiceChannelStore, { VoiceChannelEvent } from "../../../../src/stores/VoiceChannelStore"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; + +const VoiceChannelRadio = wrapInMatrixClientContext(_VoiceChannelRadio); + +class StubVoiceChannelStore extends EventEmitter { + private _roomId: string; + public get roomId(): string { return this._roomId; } + private _audioMuted: boolean; + public get audioMuted(): boolean { return this._audioMuted; } + private _videoMuted: boolean; + public get videoMuted(): boolean { return this._videoMuted; } + + public connect = jest.fn().mockImplementation(async (roomId: string) => { + this._roomId = roomId; + this._audioMuted = true; + this._videoMuted = true; + this.emit(VoiceChannelEvent.Connect); + }); + public disconnect = jest.fn().mockImplementation(async () => { + this._roomId = null; + this.emit(VoiceChannelEvent.Disconnect); + }); + public muteAudio = jest.fn().mockImplementation(async () => { + this._audioMuted = true; + this.emit(VoiceChannelEvent.MuteAudio); + }); + public unmuteAudio = jest.fn().mockImplementation(async () => { + this._audioMuted = false; + this.emit(VoiceChannelEvent.UnmuteAudio); + }); + public muteVideo = jest.fn().mockImplementation(async () => { + this._videoMuted = true; + this.emit(VoiceChannelEvent.MuteVideo); + }); + public unmuteVideo = jest.fn().mockImplementation(async () => { + this._videoMuted = false; + this.emit(VoiceChannelEvent.UnmuteVideo); + }); +} + +describe("VoiceChannelRadio", () => { + const room = mkStubRoom("!1:example.org"); + room.isCallRoom.mockReturnValue(true); + + beforeEach(() => { + stubClient(); + DMRoomMap.makeShared(); + // Stub out the VoiceChannelStore + jest.spyOn(VoiceChannelStore, "instance", "get").mockReturnValue(new StubVoiceChannelStore()); + }); + + it("shows when connecting voice", async () => { + const radio = mount(); + expect(radio.children().children().exists()).toEqual(false); + + act(() => { VoiceChannelStore.instance.connect("!1:example.org"); }); + radio.update(); + expect(radio.children().children().exists()).toEqual(true); + }); + + it("hides when disconnecting voice", () => { + VoiceChannelStore.instance.connect("!1:example.org"); + const radio = mount(); + expect(radio.children().children().exists()).toEqual(true); + + act(() => { VoiceChannelStore.instance.disconnect(); }); + radio.update(); + expect(radio.children().children().exists()).toEqual(false); + }); + + describe("disconnect button", () => { + it("works", () => { + VoiceChannelStore.instance.connect("!1:example.org"); + const radio = mount(); + + act(() => { + radio.find("AccessibleButton.mx_VoiceChannelRadio_disconnectButton").simulate("click"); + }); + expect(VoiceChannelStore.instance.disconnect).toHaveBeenCalled(); + }); + }); + + describe("video button", () => { + it("works", () => { + VoiceChannelStore.instance.connect("!1:example.org"); + const radio = mount(); + + act(() => { + radio.find("AccessibleButton.mx_VoiceChannelRadio_videoButton").simulate("click"); + }); + expect(VoiceChannelStore.instance.unmuteVideo).toHaveBeenCalled(); + + act(() => { + radio.find("AccessibleButton.mx_VoiceChannelRadio_videoButton").simulate("click"); + }); + expect(VoiceChannelStore.instance.muteVideo).toHaveBeenCalled(); + }); + }); + + describe("audio button", () => { + it("works", () => { + VoiceChannelStore.instance.connect("!1:example.org"); + const radio = mount(); + + act(() => { + radio.find("AccessibleButton.mx_VoiceChannelRadio_audioButton").simulate("click"); + }); + expect(VoiceChannelStore.instance.unmuteAudio).toHaveBeenCalled(); + + act(() => { + radio.find("AccessibleButton.mx_VoiceChannelRadio_audioButton").simulate("click"); + }); + expect(VoiceChannelStore.instance.muteAudio).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/end-to-end-tests/src/usecases/accept-invite.ts b/test/end-to-end-tests/src/usecases/accept-invite.ts index 76510af8b93..727497dad61 100644 --- a/test/end-to-end-tests/src/usecases/accept-invite.ts +++ b/test/end-to-end-tests/src/usecases/accept-invite.ts @@ -21,7 +21,7 @@ import { ElementSession } from "../session"; export async function acceptInvite(session: ElementSession, name: string): Promise { session.log.step(`accepts "${name}" invite`); const inviteSublist = await findSublist(session, "invites"); - const invitesHandles = await inviteSublist.$$(".mx_RoomTile_name"); + const invitesHandles = await inviteSublist.$$(".mx_RoomTile_title"); const invitesWithText = await Promise.all(invitesHandles.map(async (inviteHandle) => { const text = await session.innerText(inviteHandle); return { inviteHandle, text }; diff --git a/test/end-to-end-tests/src/usecases/select-room.ts b/test/end-to-end-tests/src/usecases/select-room.ts index 10a37fe5458..29bb726c5c8 100644 --- a/test/end-to-end-tests/src/usecases/select-room.ts +++ b/test/end-to-end-tests/src/usecases/select-room.ts @@ -20,7 +20,7 @@ import { ElementSession } from "../session"; export async function selectRoom(session: ElementSession, name: string): Promise { session.log.step(`select "${name}" room`); const inviteSublist = await findSublist(session, "rooms"); - const invitesHandles = await inviteSublist.$$(".mx_RoomTile_name"); + const invitesHandles = await inviteSublist.$$(".mx_RoomTile_title"); const invitesWithText = await Promise.all(invitesHandles.map(async (roomHandle) => { const text = await session.innerText(roomHandle); return { roomHandle, text }; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index b7b39d15ce5..a3e8ac08044 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -353,7 +353,8 @@ export function mkStubRoom(roomId: string = null, name: string, client: MatrixCl name, getAvatarUrl: () => 'mxc://avatar.url/room.png', getMxcAvatarUrl: () => 'mxc://avatar.url/room.png', - isSpaceRoom: jest.fn(() => false), + isSpaceRoom: jest.fn().mockReturnValue(false), + isCallRoom: jest.fn().mockReturnValue(false), getUnreadNotificationCount: jest.fn(() => 0), getEventReadUpTo: jest.fn(() => null), getCanonicalAlias: jest.fn(),