Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Improve UX for Jitsi by adding local echo for widgets #2035

Merged
merged 21 commits into from
Jul 19, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/CallHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion src/components/structures/RoomView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();

Expand Down
6 changes: 6 additions & 0 deletions src/components/views/elements/AppTile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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});
});
Expand Down
22 changes: 20 additions & 2 deletions src/components/views/rooms/AppsDrawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand All @@ -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);
},

Expand Down Expand Up @@ -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);
});
},

Expand Down Expand Up @@ -200,10 +206,22 @@ module.exports = React.createClass({
</div>;
}

let spinner;
if (
apps.length === 0 && WidgetEchoStore.roomHasPendingWidgets(
this.props.room.roomId,
WidgetUtils.getRoomWidgets(this.props.room),
)
) {
const Loader = sdk.getComponent("elements.Spinner");
spinner = <Loader />;
}

return (
<div className={'mx_AppsDrawer' + (this.props.hide ? ' mx_AppsDrawer_hidden' : '')}>
<div id='apps' className='mx_AppsContainer'>
{ apps }
{ spinner }
</div>
{ this._canUserModify() && addWidget }
</div>
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/stores/RoomViewStore.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As it happens this does need a (c) 2018


Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
108 changes: 108 additions & 0 deletions src/stores/WidgetEchoStore.js
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory, you could have a class that represents widget data within an event which doesn't care about MatrixEvents. Then you'd just need instantiate one of those as per usual (not a fake one).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, true, it just means changing everything else to add that layer of abstraction

* 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;
13 changes: 11 additions & 2 deletions src/utils/WidgetUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's worth commenting what happens when the timeout elapses.

// before waitFor[Room/User]Widget rejects its promise
const WIDGET_WAIT_TIME = 20000;
import SettingsStore from "../settings/SettingsStore";

/**
Expand Down Expand Up @@ -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);
});
}
Expand Down Expand Up @@ -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);
});
}
Expand Down Expand Up @@ -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);
});
}

Expand Down