From 1ffc6d5bd34fa2d2e87c0ea533c7cd2d9104cf5f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 16 Sep 2020 14:35:50 -0600 Subject: [PATCH] Make the hangup button do things for conference calls Behaviour constraints: * If you're not in the conference, use a grey button that does nothing. * If you're in the conference, show a button: * If you're able to modify widgets in the room, annotate it in the context of ending the call for everyone and remove the widget. Use a confirmation dialog. * If you're not able to modify widgets in the room, hang up. For this we know that persistent Jitsi widgets will mean that the user is in the call, so we use that to determine if they are actually participating. --- res/css/views/rooms/_MessageComposer.scss | 2 +- src/CallHandler.js | 77 ++++++++++++------- src/WidgetMessaging.js | 11 +++ src/components/views/rooms/MessageComposer.js | 63 +++++++++++++-- src/i18n/strings/en_EN.json | 7 +- src/stores/WidgetStore.ts | 19 +++++ src/widgets/WidgetApi.ts | 7 +- 7 files changed, 144 insertions(+), 42 deletions(-) diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index a403a8dc4c..71c0db947e 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -217,7 +217,7 @@ limitations under the License. } } - &.mx_MessageComposer_hangup::before { + &.mx_MessageComposer_hangup:not(.mx_AccessibleButton_disabled)::before { background-color: $warning-color; } } diff --git a/src/CallHandler.js b/src/CallHandler.js index ad40332af5..e40c97f025 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -70,6 +70,8 @@ import {base32} from "rfc4648"; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; +import WidgetStore from "./stores/WidgetStore"; +import ActiveWidgetStore from "./stores/ActiveWidgetStore"; global.mxCalls = { //room_id: MatrixCall @@ -310,6 +312,14 @@ function _onAction(payload) { console.info("Place conference call in %s", payload.room_id); _startCallApp(payload.room_id, payload.type); break; + case 'end_conference': + console.info("Terminating conference call in %s", payload.room_id); + _terminateCallApp(payload.room_id); + break; + case 'hangup_conference': + console.info("Leaving conference call in %s", payload.room_id); + _hangupWithCallApp(payload.room_id); + break; case 'incoming_call': { if (callHandler.getAnyActiveCall()) { @@ -357,10 +367,12 @@ async function _startCallApp(roomId, type) { show: true, }); + // prevent double clicking the call button const room = MatrixClientPeg.get().getRoom(roomId); const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI); - - if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI)) { + const hasJitsi = currentJitsiWidgets.length > 0 + || WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI); + if (hasJitsi) { Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, { title: _t('Call in Progress'), description: _t('A call is currently being placed!'), @@ -368,33 +380,6 @@ async function _startCallApp(roomId, type) { return; } - if (currentJitsiWidgets.length > 0) { - console.warn( - "Refusing to start conference call widget in " + roomId + - " a conference call widget is already present", - ); - - if (WidgetUtils.canUserModifyWidgets(roomId)) { - Modal.createTrackedDialog('Already have Jitsi Widget', '', QuestionDialog, { - title: _t('End Call'), - description: _t('Remove the group call from the room?'), - button: _t('End Call'), - cancelButton: _t('Cancel'), - onFinished: (endCall) => { - if (endCall) { - WidgetUtils.setRoomWidget(roomId, currentJitsiWidgets[0].getContent()['id']); - } - }, - }); - } else { - Modal.createTrackedDialog('Already have Jitsi Widget', '', ErrorDialog, { - title: _t('Call in Progress'), - description: _t("You don't have permission to remove the call from the room"), - }); - } - return; - } - const jitsiDomain = Jitsi.getInstance().preferredDomain; const jitsiAuth = await Jitsi.getInstance().getJitsiAuth(); let confId; @@ -444,6 +429,40 @@ async function _startCallApp(roomId, type) { }); } +function _terminateCallApp(roomId) { + Modal.createTrackedDialog('Confirm Jitsi Terminate', '', QuestionDialog, { + hasCancelButton: true, + title: _t("End conference"), + description: _t("Ending the conference will end the call for everyone. Continue?"), + button: _t("End conference"), + onFinished: (proceed) => { + if (!proceed) return; + + // We'll just obliterate them all. There should only ever be one, but might as well + // be safe. + const roomInfo = WidgetStore.instance.getRoom(roomId); + const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); + jitsiWidgets.forEach(w => { + // setting invalid content removes it + WidgetUtils.setRoomWidget(roomId, w.id); + }); + }, + }); +} + +function _hangupWithCallApp(roomId) { + const roomInfo = WidgetStore.instance.getRoom(roomId); + if (!roomInfo) return; // "should never happen" clauses go here + + const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); + jitsiWidgets.forEach(w => { + const messaging = ActiveWidgetStore.getWidgetMessaging(w.id); + if (!messaging) return; // more "should never happen" words + + messaging.hangup(); + }); +} + // FIXME: Nasty way of making sure we only register // with the dispatcher once if (!global.mxCallHandler) { diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js index c68e926ac1..0f8626ec66 100644 --- a/src/WidgetMessaging.js +++ b/src/WidgetMessaging.js @@ -107,6 +107,17 @@ export default class WidgetMessaging { }); } + /** + * Tells the widget to hang up on its call. + * @returns {Promise<*>} Resolves when teh widget has acknowledged the message. + */ + hangup() { + return this.messageToWidget({ + api: OUTBOUND_API_NAME, + action: KnownWidgetActions.Hangup, + }); + } + /** * Request a screenshot from a widget * @return {Promise} To be resolved with screenshot data when it has been generated diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 81c2ae7a33..3eab58557e 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018 New Vector Ltd +Copyright 2020 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. @@ -32,6 +33,10 @@ import {aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu} from import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import ReplyPreview from "./ReplyPreview"; import {UIFeature} from "../../../settings/UIFeature"; +import WidgetStore from "../../../stores/WidgetStore"; +import WidgetUtils from "../../../utils/WidgetUtils"; +import {UPDATE_EVENT} from "../../../stores/AsyncStore"; +import ActiveWidgetStore from "../../../stores/ActiveWidgetStore"; function ComposerAvatar(props) { const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); @@ -85,8 +90,15 @@ VideoCallButton.propTypes = { }; function HangupButton(props) { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const onHangupClick = () => { + if (props.isConference) { + dis.dispatch({ + action: props.canEndConference ? 'end_conference' : 'hangup_conference', + room_id: props.roomId, + }); + return; + } + const call = CallHandler.getCallForRoom(props.roomId); if (!call) { return; @@ -98,14 +110,28 @@ function HangupButton(props) { room_id: call.roomId, }); }; - return (); + title={tooltip} + disabled={!canLeaveConference} + /> + ); } HangupButton.propTypes = { roomId: PropTypes.string.isRequired, + isConference: PropTypes.bool.isRequired, + canEndConference: PropTypes.bool, + isInConference: PropTypes.bool, }; const EmojiButton = ({addEmoji}) => { @@ -226,12 +252,17 @@ export default class MessageComposer extends React.Component { this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); this._onTombstoneClick = this._onTombstoneClick.bind(this); this.renderPlaceholderText = this.renderPlaceholderText.bind(this); + WidgetStore.instance.on(UPDATE_EVENT, this._onWidgetUpdate); + ActiveWidgetStore.on('update', this._onActiveWidgetUpdate); this._dispatcherRef = null; + this.state = { isQuoting: Boolean(RoomViewStore.getQuotingEvent()), tombstone: this._getRoomTombstone(), canSendMessages: this.props.room.maySendMessage(), showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"), + hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room), + joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room), }; } @@ -247,6 +278,14 @@ export default class MessageComposer extends React.Component { } }; + _onWidgetUpdate = () => { + this.setState({hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room)}); + }; + + _onActiveWidgetUpdate = () => { + this.setState({joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room)}); + }; + componentDidMount() { this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents); @@ -277,6 +316,8 @@ export default class MessageComposer extends React.Component { if (this._roomStoreToken) { this._roomStoreToken.remove(); } + WidgetStore.instance.removeListener(UPDATE_EVENT, this._onWidgetUpdate); + ActiveWidgetStore.removeListener('update', this._onActiveWidgetUpdate); dis.unregister(this.dispatcherRef); } @@ -392,9 +433,19 @@ export default class MessageComposer extends React.Component { } if (this.state.showCallButtons) { - if (callInProgress) { + if (this.state.hasConference) { + const canEndConf = WidgetUtils.canUserModifyWidgets(this.props.room.roomId); controls.push( - , + , + ); + } else if (callInProgress) { + controls.push( + , ); } else { controls.push( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b2b4e01202..b5ecf26cb7 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -50,12 +50,10 @@ "You cannot place a call with yourself.": "You cannot place a call with yourself.", "Call in Progress": "Call in Progress", "A call is currently being placed!": "A call is currently being placed!", - "End Call": "End Call", - "Remove the group call from the room?": "Remove the group call from the room?", - "Cancel": "Cancel", - "You don't have permission to remove the call from the room": "You don't have permission to remove the call from the room", "Permission Required": "Permission Required", "You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room", + "End conference": "End conference", + "Ending the conference will end the call for everyone. Continue?": "Ending the conference will end the call for everyone. Continue?", "Replying With Files": "Replying With Files", "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "At this time it is not possible to reply with a file. Would you like to upload this file without replying?", "Continue": "Continue", @@ -143,6 +141,7 @@ "Cancel entering passphrase?": "Cancel entering passphrase?", "Are you sure you want to cancel entering passphrase?": "Are you sure you want to cancel entering passphrase?", "Go Back": "Go Back", + "Cancel": "Cancel", "Setting up keys": "Setting up keys", "Messages": "Messages", "Actions": "Actions", diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index 10327ce4e9..be2233961b 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -22,6 +22,7 @@ import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; import SettingsStore from "../settings/SettingsStore"; import WidgetEchoStore from "../stores/WidgetEchoStore"; +import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import WidgetUtils from "../utils/WidgetUtils"; import {SettingLevel} from "../settings/SettingLevel"; import {WidgetType} from "../widgets/WidgetType"; @@ -206,6 +207,24 @@ export default class WidgetStore extends AsyncStoreWithClient { } return roomInfo.widgets; } + + public doesRoomHaveConference(room: Room): boolean { + const roomInfo = this.getRoom(room.roomId); + if (!roomInfo) return false; + + const currentWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); + const hasPendingWidgets = WidgetEchoStore.roomHasPendingWidgetsOfType(room.roomId, [], WidgetType.JITSI); + return currentWidgets.length > 0 || hasPendingWidgets; + } + + public isJoinedToConferenceIn(room: Room): boolean { + const roomInfo = this.getRoom(room.roomId); + if (!roomInfo) return false; + + // A persistent conference widget indicates that we're participating + const widgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); + return widgets.some(w => ActiveWidgetStore.getWidgetPersistence(w.id)); + } } window.mxWidgetStore = WidgetStore.instance; diff --git a/src/widgets/WidgetApi.ts b/src/widgets/WidgetApi.ts index 672cbf2a56..c25d607948 100644 --- a/src/widgets/WidgetApi.ts +++ b/src/widgets/WidgetApi.ts @@ -39,6 +39,7 @@ export enum KnownWidgetActions { SetAlwaysOnScreen = "set_always_on_screen", ClientReady = "im.vector.ready", Terminate = "im.vector.terminate", + Hangup = "im.vector.hangup", } export type WidgetAction = KnownWidgetActions | string; @@ -119,13 +120,15 @@ export class WidgetApi extends EventEmitter { // Automatically acknowledge so we can move on this.replyToRequest(payload, {}); - } else if (payload.action === KnownWidgetActions.Terminate) { + } else if (payload.action === KnownWidgetActions.Terminate + || payload.action === KnownWidgetActions.Hangup) { // Finalization needs to be async, so postpone with a promise let finalizePromise = Promise.resolve(); const wait = (promise) => { finalizePromise = finalizePromise.then(() => promise); }; - this.emit('terminate', wait); + const emitName = payload.action === KnownWidgetActions.Terminate ? 'terminate' : 'hangup'; + this.emit(emitName, wait); Promise.resolve(finalizePromise).then(() => { // Acknowledge that we're shut down now this.replyToRequest(payload, {});