diff --git a/src/CallHandler.js b/src/CallHandler.js index 4e6ceb6382..c8179b8b3c 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -62,6 +62,7 @@ import dis from './dispatcher'; import { showUnknownDeviceDialogForCalls } from './cryptodevices'; import SettingsStore from "./settings/SettingsStore"; import WidgetUtils from './utils/WidgetUtils'; +import WidgetEchoStore from './stores/WidgetEchoStore'; import ScalarAuthClient from './ScalarAuthClient'; global.mxCalls = { @@ -431,12 +432,19 @@ async function _startCallApp(roomId, type) { }); const room = MatrixClientPeg.get().getRoom(roomId); - if (!room) { - console.error("Attempted to start conference call widget in unknown room: " + roomId); + const currentRoomWidgets = WidgetUtils.getRoomWidgets(room); + + if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentRoomWidgets, 'jitsi')) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + + Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, { + title: _t('Call in Progress'), + description: _t('A call is currently being placed!'), + }); return; } - const currentJitsiWidgets = WidgetUtils.getRoomWidgets(room).filter((ev) => { + const currentJitsiWidgets = currentRoomWidgets.filter((ev) => { return ev.getContent().type === 'jitsi'; }); if (currentJitsiWidgets.length > 0) { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index b9d0843635..0325b3d9a6 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -45,6 +45,7 @@ import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; import RoomViewStore from '../../stores/RoomViewStore'; import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; +import WidgetEchoStore from '../../stores/WidgetEchoStore'; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import WidgetUtils from '../../utils/WidgetUtils'; @@ -153,6 +154,8 @@ module.exports = React.createClass({ // Start listening for RoomViewStore updates this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); this._onRoomViewStoreUpdate(true); + + WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate); }, _onRoomViewStoreUpdate: function(initial) { @@ -243,6 +246,12 @@ module.exports = React.createClass({ } }, + _onWidgetEchoStoreUpdate: function() { + this.setState({ + showApps: this._shouldShowApps(this.state.room), + }); + }, + _setupRoom: function(room, roomId, joining, shouldPeek) { // if this is an unknown room then we're in one of three states: // - This is a room we can peek into (search engine) (we can /peek) @@ -319,7 +328,9 @@ module.exports = React.createClass({ return false; } - return WidgetUtils.getRoomWidgets(room).length > 0; + const widgets = WidgetEchoStore.getEchoedRoomWidgets(room.roomId, WidgetUtils.getRoomWidgets(room)); + + return widgets.length > 0 || WidgetEchoStore.roomHasPendingWidgets(room.roomId, WidgetUtils.getRoomWidgets(room)); }, componentDidMount: function() { @@ -414,6 +425,8 @@ module.exports = React.createClass({ this._roomStoreToken.remove(); } + WidgetEchoStore.removeListener('update', this._onWidgetEchoStoreUpdate); + // cancel any pending calls to the rate_limited_funcs this._updateRoomMembers.cancelPendingCall(); diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index e287abd07a..8ca694b8c3 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -325,6 +325,12 @@ export default class AppTile extends React.Component { this.props.id, ).catch((e) => { console.error('Failed to delete widget', e); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + + Modal.createTrackedDialog('Failed to remove widget', '', ErrorDialog, { + title: _t('Failed to remove widget'), + description: _t('An error ocurred whilst trying to remove the widget from the room'), + }); }).finally(() => { this.setState({deleting: false}); }); diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 80f899f8e6..e6fe445b45 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -29,6 +29,7 @@ import ScalarAuthClient from '../../../ScalarAuthClient'; import ScalarMessaging from '../../../ScalarMessaging'; import { _t } from '../../../languageHandler'; import WidgetUtils from '../../../utils/WidgetUtils'; +import WidgetEchoStore from "../../../stores/WidgetEchoStore"; // The maximum number of widgets that can be added in a room const MAX_WIDGETS = 2; @@ -57,6 +58,7 @@ module.exports = React.createClass({ componentWillMount: function() { ScalarMessaging.startListening(); MatrixClientPeg.get().on('RoomState.events', this.onRoomStateEvents); + WidgetEchoStore.on('update', this._updateApps); }, componentDidMount: function() { @@ -82,6 +84,7 @@ module.exports = React.createClass({ if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents); } + WidgetEchoStore.removeListener('update', this._updateApps); dis.unregister(this.dispatcherRef); }, @@ -114,8 +117,11 @@ module.exports = React.createClass({ }, _getApps: function() { - return WidgetUtils.getRoomWidgets(this.props.room).map((ev) => { - return WidgetUtils.makeAppConfig(ev.getStateKey(), ev.getContent(), ev.sender, this.props.room.roomId); + const widgets = WidgetEchoStore.getEchoedRoomWidgets( + this.props.room.roomId, WidgetUtils.getRoomWidgets(this.props.room), + ); + return widgets.map((ev) => { + return WidgetUtils.makeAppConfig(ev.getStateKey(), ev.getContent(), ev.sender); }); }, @@ -200,10 +206,22 @@ module.exports = React.createClass({ ; } + let spinner; + if ( + apps.length === 0 && WidgetEchoStore.roomHasPendingWidgets( + this.props.room.roomId, + WidgetUtils.getRoomWidgets(this.props.room), + ) + ) { + const Loader = sdk.getComponent("elements.Spinner"); + spinner = ; + } + return (
{ apps } + { spinner }
{ this._canUserModify() && addWidget }
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ee5006ce4c..9acde2b80c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -42,6 +42,7 @@ "Could not connect to the integration server": "Could not connect to the integration server", "A conference call could not be started because the intgrations server is not available": "A conference call could not be started because the intgrations server is not available", "Call in Progress": "Call in Progress", + "A call is currently being placed!": "A call is currently being placed!", "A call is already in progress!": "A call is already in progress!", "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", @@ -695,6 +696,8 @@ "Delete Widget": "Delete Widget", "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?", "Delete widget": "Delete widget", + "Failed to remove widget": "Failed to remove widget", + "An error ocurred whilst trying to remove the widget from the room": "An error ocurred whilst trying to remove the widget from the room", "Revoke widget access": "Revoke widget access", "Minimize apps": "Minimize apps", "Reload widget": "Reload widget", diff --git a/src/stores/RoomViewStore.js b/src/stores/RoomViewStore.js index 35cfe69086..fed0d7b4a1 100644 --- a/src/stores/RoomViewStore.js +++ b/src/stores/RoomViewStore.js @@ -1,6 +1,6 @@ /* Copyright 2017 Vector Creations Ltd -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/stores/WidgetEchoStore.js b/src/stores/WidgetEchoStore.js new file mode 100644 index 0000000000..0d14ed1d60 --- /dev/null +++ b/src/stores/WidgetEchoStore.js @@ -0,0 +1,108 @@ +/* +Copyright 2018 New Vector Ltd + +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'; + +/** + * Acts as a place to get & set widget state, storing local echo state and + * proxying through state from the js-sdk. + */ +class WidgetEchoStore extends EventEmitter { + constructor() { + super(); + + this._roomWidgetEcho = { + // Map as below. Object is the content of the widget state event, + // so for widgets that have been deleted locally, the object is empty. + // roomId: { + // widgetId: [object] + // } + }; + } + + /** + * Gets the widgets for a room, substracting those that are pending deletion. + * Widgets that are pending addition are not included, since widgets are + * represted as MatrixEvents, so to do this we'd have to create fake MatrixEvents, + * and we don't really need the actual widget events anyway since we just want to + * show a spinner / prevent widgets being added twice. + * + * @param {Room} roomId The ID of the room to get widgets for + * @param {MatrixEvent[]} currentRoomWidgets Current widgets for the room + * @returns {MatrixEvent[]} List of widgets in the room, minus any pending removal + */ + getEchoedRoomWidgets(roomId, currentRoomWidgets) { + const echoedWidgets = []; + + const roomEchoState = Object.assign({}, this._roomWidgetEcho[roomId]); + + for (const w of currentRoomWidgets) { + const widgetId = w.getStateKey(); + // If there's no echo, or the echo still has a widget present, show the *old* widget + // we don't include widgets that have changed for the same reason we don't include new ones, + // ie. we'd need to fake matrix events to do so and therte's currently no need. + if (!roomEchoState[widgetId] || Object.keys(roomEchoState[widgetId]).length !== 0) { + echoedWidgets.push(w); + } + delete roomEchoState[widgetId]; + } + + return echoedWidgets; + } + + roomHasPendingWidgetsOfType(roomId, currentRoomWidgets, type) { + const roomEchoState = Object.assign({}, this._roomWidgetEcho[roomId]); + + // any widget IDs that are already in the room are not pending, so + // echoes for them don't count as pending. + for (const w of currentRoomWidgets) { + const widgetId = w.getStateKey(); + delete roomEchoState[widgetId]; + } + + // if there's anything left then there are pending widgets. + if (type === undefined) { + return Object.keys(roomEchoState).length > 0; + } else { + return Object.values(roomEchoState).some((widget) => { + return widget.type === type; + }); + } + } + + roomHasPendingWidgets(roomId, currentRoomWidgets) { + return this.roomHasPendingWidgetsOfType(roomId, currentRoomWidgets); + } + + setRoomWidgetEcho(roomId, widgetId, state) { + if (this._roomWidgetEcho[roomId] === undefined) this._roomWidgetEcho[roomId] = {}; + + this._roomWidgetEcho[roomId][widgetId] = state; + this.emit('update'); + } + + removeRoomWidgetEcho(roomId, widgetId) { + delete this._roomWidgetEcho[roomId][widgetId]; + if (Object.keys(this._roomWidgetEcho[roomId]).length === 0) delete this._roomWidgetEcho[roomId]; + this.emit('update'); + } +} + +let singletonWidgetEchoStore = null; +if (!singletonWidgetEchoStore) { + singletonWidgetEchoStore = new WidgetEchoStore(); +} +module.exports = singletonWidgetEchoStore; diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js index 98239b3cec..008bed1005 100644 --- a/src/utils/WidgetUtils.js +++ b/src/utils/WidgetUtils.js @@ -19,6 +19,11 @@ import MatrixClientPeg from '../MatrixClientPeg'; import SdkConfig from "../SdkConfig"; import dis from '../dispatcher'; import * as url from "url"; +import WidgetEchoStore from '../stores/WidgetEchoStore'; + +// How long we wait for the state event echo to come back from the server +// before waitFor[Room/User]Widget rejects its promise +const WIDGET_WAIT_TIME = 20000; import SettingsStore from "../settings/SettingsStore"; /** @@ -155,7 +160,7 @@ export default class WidgetUtils { const timerId = setTimeout(() => { MatrixClientPeg.get().removeListener('accountData', onAccountData); reject(new Error("Timed out waiting for widget ID " + widgetId + " to appear")); - }, 10000); + }, WIDGET_WAIT_TIME); MatrixClientPeg.get().on('accountData', onAccountData); }); } @@ -208,7 +213,7 @@ export default class WidgetUtils { const timerId = setTimeout(() => { MatrixClientPeg.get().removeListener('RoomState.events', onRoomStateEvents); reject(new Error("Timed out waiting for widget ID " + widgetId + " to appear")); - }, 10000); + }, WIDGET_WAIT_TIME); MatrixClientPeg.get().on('RoomState.events', onRoomStateEvents); }); } @@ -271,11 +276,15 @@ export default class WidgetUtils { content = {}; } + WidgetEchoStore.setRoomWidgetEcho(roomId, widgetId, content); + const client = MatrixClientPeg.get(); // TODO - Room widgets need to be moved to 'm.widget' state events // https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing return client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).then(() => { return WidgetUtils.waitForRoomWidget(widgetId, roomId, addingWidget); + }).finally(() => { + WidgetEchoStore.removeRoomWidgetEcho(roomId, widgetId); }); }