From 38de8a129bdf5359714f5721ebcfa38195cdcda4 Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Thu, 25 Jan 2018 21:45:15 +0100 Subject: [PATCH 01/38] Add transaction capability to asyncActions for relating pending/success/failure actions. Particularly useful for mapping a failure to a pending action to roll back any optimistic updates. --- src/actions/actionCreators.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/actions/actionCreators.js b/src/actions/actionCreators.js index 0238eee8c0..697930414c 100644 --- a/src/actions/actionCreators.js +++ b/src/actions/actionCreators.js @@ -31,18 +31,30 @@ limitations under the License. * `${id}.pending` and either * `${id}.success` or * `${id}.failure`. + * + * The shape of each are: + * { action: '${id}.pending', request, asyncId } + * { action: '${id}.success', result, asyncId } + * { action: '${id}.failure', err, asyncId } + * + * where `request` is returned by `pendingFn`, result + * is the result of the promise returned by `fn` and + * `asyncId` is a unique ID for each dispatch of the + * asynchronous action. */ export function asyncAction(id, fn, pendingFn) { return (dispatch) => { + const asyncId = Math.random().toString(16).slice(2, 10); dispatch({ action: id + '.pending', request: typeof pendingFn === 'function' ? pendingFn() : undefined, + asyncId, }); fn().then((result) => { - dispatch({action: id + '.success', result}); + dispatch({action: id + '.success', result, asyncId}); }).catch((err) => { - dispatch({action: id + '.failure', err}); + dispatch({action: id + '.failure', err, asyncId}); }); }; } From 815f52587bd532bb916113fe205b3bbf2df6140b Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Thu, 25 Jan 2018 21:53:34 +0100 Subject: [PATCH 02/38] Move TagPanel out of LoggedInView (...and into LeftPanel in riot-web. Can we merge the projects yet?) --- src/components/structures/LoggedInView.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index e97d9dd0a1..d7fe699156 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -208,7 +208,6 @@ const LoggedInView = React.createClass({ }, render: function() { - const TagPanel = sdk.getComponent('structures.TagPanel'); const LeftPanel = sdk.getComponent('structures.LeftPanel'); const RightPanel = sdk.getComponent('structures.RightPanel'); const RoomView = sdk.getComponent('structures.RoomView'); @@ -330,7 +329,6 @@ const LoggedInView = React.createClass({
{ topBar }
- { SettingsStore.isFeatureEnabled("feature_tag_panel") ? :
} Date: Thu, 25 Jan 2018 21:58:35 +0100 Subject: [PATCH 03/38] Remove DragDropContext from TagPanel and RoomList So that we can have one context that can handle DND between the TagPanel and RoomList. --- src/components/structures/TagPanel.js | 53 ++--- src/components/views/rooms/RoomList.js | 304 ++++++++----------------- 2 files changed, 118 insertions(+), 239 deletions(-) diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 1cd3f04f9d..8c790edc03 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -20,12 +20,11 @@ import { MatrixClient } from 'matrix-js-sdk'; import TagOrderStore from '../../stores/TagOrderStore'; import GroupActions from '../../actions/GroupActions'; -import TagOrderActions from '../../actions/TagOrderActions'; import sdk from '../../index'; import dis from '../../dispatcher'; -import { DragDropContext, Droppable } from 'react-beautiful-dnd'; +import { Droppable } from 'react-beautiful-dnd'; const TagPanel = React.createClass({ displayName: 'TagPanel', @@ -82,22 +81,6 @@ const TagPanel = React.createClass({ dis.dispatch({action: 'view_create_group'}); }, - onTagTileEndDrag(result) { - // Dragged to an invalid destination, not onto a droppable - if (!result.destination) { - return; - } - - // Dispatch synchronously so that the TagPanel receives an - // optimistic update from TagOrderStore before the previous - // state is shown. - dis.dispatch(TagOrderActions.moveTag( - this.context.matrixClient, - result.draggableId, - result.destination.index, - ), true); - }, - render() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const TintableSvg = sdk.getComponent('elements.TintableSvg'); @@ -112,24 +95,22 @@ const TagPanel = React.createClass({ />; }); return
- - - { (provided, snapshot) => ( -
- { tags } - { provided.placeholder } -
- ) } -
-
+ + { (provided, snapshot) => ( +
+ { tags } + { provided.placeholder } +
+ ) } +
diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index ca1fccd1f5..d1ef6c2f2c 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -18,7 +18,6 @@ limitations under the License. 'use strict'; const React = require("react"); const ReactDOM = require("react-dom"); -import { DragDropContext } from 'react-beautiful-dnd'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; const GeminiScrollbar = require('react-gemini-scrollbar'); @@ -33,8 +32,6 @@ const Receipt = require('../../../utils/Receipt'); import TagOrderStore from '../../../stores/TagOrderStore'; import GroupStoreCache from '../../../stores/GroupStoreCache'; -import Modal from '../../../Modal'; - const HIDE_CONFERENCE_CHANS = true; function phraseForSection(section) { @@ -278,103 +275,6 @@ module.exports = React.createClass({ this.forceUpdate(); }, - onRoomTileEndDrag: function(result) { - if (!result.destination) return; - - let newTag = result.destination.droppableId.split('_')[1]; - let prevTag = result.source.droppableId.split('_')[1]; - if (newTag === 'undefined') newTag = undefined; - if (prevTag === 'undefined') prevTag = undefined; - - const roomId = result.draggableId.split('_')[1]; - const room = MatrixClientPeg.get().getRoom(roomId); - - const newIndex = result.destination.index; - - // Evil hack to get DMs behaving - if ((prevTag === undefined && newTag === 'im.vector.fake.direct') || - (prevTag === 'im.vector.fake.direct' && newTag === undefined) - ) { - Rooms.guessAndSetDMRoom( - room, newTag === 'im.vector.fake.direct', - ).catch((err) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - console.error("Failed to set direct chat tag " + err); - Modal.createTrackedDialog('Failed to set direct chat tag', '', ErrorDialog, { - title: _t('Failed to set direct chat tag'), - description: ((err && err.message) ? err.message : _t('Operation failed')), - }); - }); - return; - } - - const hasChangedSubLists = result.source.droppableId !== result.destination.droppableId; - - let newOrder = null; - - // Is the tag ordered manually? - if (newTag && !newTag.match(/^(m\.lowpriority|im\.vector\.fake\.(invite|recent|direct|archived))$/)) { - const newList = Object.assign({}, this.state.lists[newTag]); - - // If the room was moved "down" (increasing index) in the same list we - // need to use the orders of the tiles with indices shifted by +1 - const offset = ( - newTag === prevTag && result.source.index < result.destination.index - ) ? 1 : 0; - - const prevOrder = newIndex === 0 ? - 0 : newList[offset + newIndex - 1].tags[newTag].order; - const nextOrder = newIndex === newList.length ? - 1 : newList[offset + newIndex].tags[newTag].order; - - newOrder = { - order: (prevOrder + nextOrder) / 2.0, - }; - } - - // More evilness: We will still be dealing with moving to favourites/low prio, - // but we avoid ever doing a request with 'im.vector.fake.direct`. - // - // if we moved lists, remove the old tag - if (prevTag && prevTag !== 'im.vector.fake.direct' && - hasChangedSubLists - ) { - // Optimistic update of what will happen to the room tags - delete room.tags[prevTag]; - - MatrixClientPeg.get().deleteRoomTag(roomId, prevTag).catch(function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - console.error("Failed to remove tag " + prevTag + " from room: " + err); - Modal.createTrackedDialog('Failed to remove tag from room', '', ErrorDialog, { - title: _t('Failed to remove tag %(tagName)s from room', {tagName: prevTag}), - description: ((err && err.message) ? err.message : _t('Operation failed')), - }); - }); - } - - // if we moved lists or the ordering changed, add the new tag - if (newTag && newTag !== 'im.vector.fake.direct' && - (hasChangedSubLists || newOrder) - ) { - // Optimistic update of what will happen to the room tags - room.tags[newTag] = newOrder; - - MatrixClientPeg.get().setRoomTag(roomId, newTag, newOrder).catch(function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - console.error("Failed to add tag " + newTag + " to room: " + err); - Modal.createTrackedDialog('Failed to add tag to room', '', ErrorDialog, { - title: _t('Failed to add tag %(tagName)s to room', {tagName: newTag}), - description: ((err && err.message) ? err.message : _t('Operation failed')), - }); - }); - } - - // Refresh to display the optimistic updates - this needs to be done in the - // same tick as the drag finishing otherwise the room will pop back to its - // previous position - hence no delayed refresh - this.refreshRoomList(); - }, - _delayedRefreshRoomList: new rate_limited_func(function() { this.refreshRoomList(); }, 500), @@ -749,116 +649,114 @@ module.exports = React.createClass({ const self = this; return ( - - -
- + +
+ - + - + - + - + - { Object.keys(self.state.lists).map((tagName) => { - if (!tagName.match(/^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/)) { - return ; - } - }) } + { Object.keys(self.state.lists).map((tagName) => { + if (!tagName.match(/^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/)) { + return ; + } + }) } - + - -
-
- + +
+
); }, }); From 701abb6a219cb278dc992c4761ef58d366e29743 Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Thu, 25 Jan 2018 22:16:03 +0100 Subject: [PATCH 04/38] Move management of room lists to RoomListStore this is part maintenance to make RoomList clearer and part allowing room list state to be modified via a dispatch. --- src/actions/MatrixActionCreators.js | 5 + src/actions/RoomListActions.js | 142 +++++++++++++++++ src/components/views/rooms/RoomList.js | 91 +++-------- src/stores/RoomListStore.js | 212 +++++++++++++++++++++++++ 4 files changed, 383 insertions(+), 67 deletions(-) create mode 100644 src/actions/RoomListActions.js create mode 100644 src/stores/RoomListStore.js diff --git a/src/actions/MatrixActionCreators.js b/src/actions/MatrixActionCreators.js index 33bdb53799..d9309d7c1c 100644 --- a/src/actions/MatrixActionCreators.js +++ b/src/actions/MatrixActionCreators.js @@ -62,6 +62,10 @@ function createAccountDataAction(matrixClient, accountDataEvent) { }; } +function createRoomTagsAction(matrixClient, roomTagsEvent, room) { + return { action: 'MatrixActions.Room.tags', room }; +} + /** * This object is responsible for dispatching actions when certain events are emitted by * the given MatrixClient. @@ -78,6 +82,7 @@ export default { start(matrixClient) { this._addMatrixClientListener(matrixClient, 'sync', createSyncAction); this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); + this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction); }, /** diff --git a/src/actions/RoomListActions.js b/src/actions/RoomListActions.js new file mode 100644 index 0000000000..3e0ea53a33 --- /dev/null +++ b/src/actions/RoomListActions.js @@ -0,0 +1,142 @@ +/* +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 { asyncAction } from './actionCreators'; +import RoomListStore from '../stores/RoomListStore'; + +import Modal from '../Modal'; +import Rooms from '../Rooms'; +import { _t } from '../languageHandler'; +import sdk from '../index'; + +const RoomListActions = {}; + +/** + * Creates an action thunk that will do an asynchronous request to + * tag room. + * + * @param {MatrixClient} matrixClient the matrix client to set the + * account data on. + * @param {Room} room the room to tag. + * @param {string} oldTag the tag to remove (unless oldTag ==== newTag) + * @param {string} newTag the tag with which to tag the room. + * @param {?number} oldIndex the previous position of the room in the + * list of rooms. + * @param {?number} newIndex the new position of the room in the list + * of rooms. + * @returns {function} an action thunk. + * @see asyncAction + */ +RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, newIndex) { + let metaData = null; + + // Is the tag ordered manually? + if (newTag && !newTag.match(/^(m\.lowpriority|im\.vector\.fake\.(invite|recent|direct|archived))$/)) { + const lists = RoomListStore.getRoomLists(); + const newList = [...lists[newTag]]; + + newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order); + + // If the room was moved "down" (increasing index) in the same list we + // need to use the orders of the tiles with indices shifted by +1 + const offset = ( + newTag === oldTag && oldIndex < newIndex + ) ? 1 : 0; + + const prevOrder = newIndex === 0 ? + 0 : newList[offset + newIndex - 1].tags[newTag].order; + const nextOrder = newIndex === newList.length ? + 1 : newList[offset + newIndex].tags[newTag].order; + + metaData = { + order: (prevOrder + nextOrder) / 2.0, + }; + } + + return asyncAction('RoomListActions.tagRoom', () => { + const promises = []; + const roomId = room.roomId; + + // Evil hack to get DMs behaving + if ((oldTag === undefined && newTag === 'im.vector.fake.direct') || + (oldTag === 'im.vector.fake.direct' && newTag === undefined) + ) { + return Rooms.guessAndSetDMRoom( + room, newTag === 'im.vector.fake.direct', + ).catch((err) => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to set direct chat tag " + err); + Modal.createTrackedDialog('Failed to set direct chat tag', '', ErrorDialog, { + title: _t('Failed to set direct chat tag'), + description: ((err && err.message) ? err.message : _t('Operation failed')), + }); + }); + } + + const hasChangedSubLists = oldTag !== newTag; + + // More evilness: We will still be dealing with moving to favourites/low prio, + // but we avoid ever doing a request with 'im.vector.fake.direct`. + // + // if we moved lists, remove the old tag + if (oldTag && oldTag !== 'im.vector.fake.direct' && + hasChangedSubLists + ) { + const promiseToDelete = matrixClient.deleteRoomTag( + roomId, oldTag, + ).catch(function(err) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to remove tag " + oldTag + " from room: " + err); + Modal.createTrackedDialog('Failed to remove tag from room', '', ErrorDialog, { + title: _t('Failed to remove tag %(tagName)s from room', {tagName: oldTag}), + description: ((err && err.message) ? err.message : _t('Operation failed')), + }); + }); + + promises.push(promiseToDelete); + } + + // if we moved lists or the ordering changed, add the new tag + if (newTag && newTag !== 'im.vector.fake.direct' && + (hasChangedSubLists || metaData) + ) { + // Optimistic update of what will happen to the room tags + room.tags[newTag] = metaData; + + const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to add tag " + newTag + " to room: " + err); + Modal.createTrackedDialog('Failed to add tag to room', '', ErrorDialog, { + title: _t('Failed to add tag %(tagName)s to room', {tagName: newTag}), + description: ((err && err.message) ? err.message : _t('Operation failed')), + }); + + throw err; + }); + + promises.push(promiseToAdd); + } + + return Promise.all(promises); + }, () => { + // For an optimistic update + return { + room, oldTag, newTag, metaData, + }; + }); +}; + +export default RoomListActions; diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index d1ef6c2f2c..ad85beac12 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -26,10 +26,11 @@ const CallHandler = require('../../../CallHandler'); const dis = require("../../../dispatcher"); const sdk = require('../../../index'); const rate_limited_func = require('../../../ratelimitedfunc'); -const Rooms = require('../../../Rooms'); +import * as Rooms from '../../../Rooms'; import DMRoomMap from '../../../utils/DMRoomMap'; const Receipt = require('../../../utils/Receipt'); import TagOrderStore from '../../../stores/TagOrderStore'; +import RoomListStore from '../../../stores/RoomListStore'; import GroupStoreCache from '../../../stores/GroupStoreCache'; const HIDE_CONFERENCE_CHANS = true; @@ -77,7 +78,6 @@ module.exports = React.createClass({ cli.on("deleteRoom", this.onDeleteRoom); cli.on("Room.timeline", this.onRoomTimeline); cli.on("Room.name", this.onRoomName); - cli.on("Room.tags", this.onRoomTags); cli.on("Room.receipt", this.onRoomReceipt); cli.on("RoomState.events", this.onRoomStateEvents); cli.on("RoomMember.name", this.onRoomMemberName); @@ -115,6 +115,10 @@ module.exports = React.createClass({ this.updateVisibleRooms(); }); + this._roomListStoreToken = RoomListStore.addListener(() => { + this._delayedRefreshRoomList(); + }); + this.refreshRoomList(); // order of the sublists @@ -175,7 +179,6 @@ module.exports = React.createClass({ MatrixClientPeg.get().removeListener("deleteRoom", this.onDeleteRoom); MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); - MatrixClientPeg.get().removeListener("Room.tags", this.onRoomTags); MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName); @@ -248,10 +251,6 @@ module.exports = React.createClass({ this._delayedRefreshRoomList(); }, - onRoomTags: function(event, room) { - this._delayedRefreshRoomList(); - }, - onRoomStateEvents: function(ev, state) { this._delayedRefreshRoomList(); }, @@ -338,7 +337,7 @@ module.exports = React.createClass({ totalRooms += l.length; } this.setState({ - lists: this.getRoomLists(), + lists, totalRoomCount: totalRooms, // Do this here so as to not render every time the selected tags // themselves change. @@ -349,70 +348,28 @@ module.exports = React.createClass({ }, getRoomLists: function() { - const lists = {}; - lists["im.vector.fake.invite"] = []; - lists["m.favourite"] = []; - lists["im.vector.fake.recent"] = []; - lists["im.vector.fake.direct"] = []; - lists["m.lowpriority"] = []; - lists["im.vector.fake.archived"] = []; + const lists = RoomListStore.getRoomLists(); - const dmRoomMap = DMRoomMap.shared(); + const filteredLists = {}; - this._visibleRooms.forEach((room, index) => { - const me = room.getMember(MatrixClientPeg.get().credentials.userId); - if (!me) return; - - // console.log("room = " + room.name + ", me.membership = " + me.membership + - // ", sender = " + me.events.member.getSender() + - // ", target = " + me.events.member.getStateKey() + - // ", prevMembership = " + me.events.member.getPrevContent().membership); - - if (me.membership == "invite") { - lists["im.vector.fake.invite"].push(room); - } else if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, me, this.props.ConferenceHandler)) { - // skip past this room & don't put it in any lists - } else if (me.membership == "join" || me.membership === "ban" || - (me.membership === "leave" && me.events.member.getSender() !== me.events.member.getStateKey())) { - // Used to split rooms via tags - const tagNames = Object.keys(room.tags); - if (tagNames.length) { - for (let i = 0; i < tagNames.length; i++) { - const tagName = tagNames[i]; - lists[tagName] = lists[tagName] || []; - lists[tagName].push(room); - } - } else if (dmRoomMap.getUserIdForRoomId(room.roomId)) { - // "Direct Message" rooms (that we're still in and that aren't otherwise tagged) - lists["im.vector.fake.direct"].push(room); - } else { - lists["im.vector.fake.recent"].push(room); + Object.keys(lists).forEach((tagName) => { + filteredLists[tagName] = lists[tagName].filter((taggedRoom) => { + // Somewhat impossible, but guard against it anyway + if (!taggedRoom) { + return; } - } else if (me.membership === "leave") { - lists["im.vector.fake.archived"].push(room); - } else { - console.error("unrecognised membership: " + me.membership + " - this should never happen"); - } + const me = taggedRoom.getMember(MatrixClientPeg.get().credentials.userId); + if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(taggedRoom, me, this.props.ConferenceHandler)) { + return; + } + + return this._visibleRooms.some((visibleRoom) => { + return visibleRoom.roomId === taggedRoom.roomId; + }); + }); }); - // we actually apply the sorting to this when receiving the prop in RoomSubLists. - - // we'll need this when we get to iterating through lists programatically - e.g. ctrl-shift-up/down -/* - this.listOrder = [ - "im.vector.fake.invite", - "m.favourite", - "im.vector.fake.recent", - "im.vector.fake.direct", - Object.keys(otherTagNames).filter(tagName=>{ - return (!tagName.match(/^m\.(favourite|lowpriority)$/)); - }).sort(), - "m.lowpriority", - "im.vector.fake.archived" - ]; -*/ - - return lists; + return filteredLists; }, _getScrollNode: function() { diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js new file mode 100644 index 0000000000..16902ef471 --- /dev/null +++ b/src/stores/RoomListStore.js @@ -0,0 +1,212 @@ +/* +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 {Store} from 'flux/utils'; +import dis from '../dispatcher'; +import DMRoomMap from '../utils/DMRoomMap'; + +/** + * A class for storing application state for categorising rooms in + * the RoomList. + */ +class RoomListStore extends Store { + constructor() { + super(dis); + + this._init(); + this._actionHistory = []; + } + + _init() { + // Initialise state + this._state = { + lists: { + "im.vector.fake.invite": [], + "m.favourite": [], + "im.vector.fake.recent": [], + "im.vector.fake.direct": [], + "m.lowpriority": [], + "im.vector.fake.archived": [], + }, + ready: false, + }; + } + + _setState(newState) { + this._state = Object.assign(this._state, newState); + console.info(this._state); + this.__emitChange(); + } + + __onDispatch(payload) { + switch (payload.action) { + // Initialise state after initial sync + case 'MatrixActions.sync': { + if (!(payload.prevState !== 'PREPARED' && payload.state === 'PREPARED')) { + break; + } + + this._generateRoomLists(payload.matrixClient); + this._actionHistory.unshift(payload); + } + break; + case 'MatrixActions.Room.tags': { + if (!this._state.ready) break; + this._updateRoomLists(payload.room); + this._actionHistory.unshift(payload); + } + break; + case 'RoomListActions.tagRoom.pending': { + this._updateRoomListsOptimistic( + payload.request.room, + payload.request.oldTag, + payload.request.newTag, + payload.request.metaData, + ); + this._actionHistory.unshift(payload); + } + break; + case 'RoomListActions.tagRoom.failure': { + this._actionHistory = this._actionHistory.filter((action) => { + return action.asyncId !== payload.asyncId; + }); + + // don't duplicate history + const history = this._actionHistory.slice(0); + this._actionHistory = []; + this._reloadFromHistory(history); + } + break; + case 'on_logged_out': { + // Reset state without pushing an update to the view, which generally assumes that + // the matrix client isn't `null` and so causing a re-render will cause NPEs. + this._init(); + this._actionHistory.unshift(payload); + } + break; + } + } + + _reloadFromHistory(history) { + this._init(); + history.forEach((action) => this.__onDispatch(action)); + } + + _updateRoomListsOptimistic(updatedRoom, oldTag, newTag, metaData) { + const newLists = {}; + + // Remove room from oldTag + Object.keys(this._state.lists).forEach((tagName) => { + if (tagName === oldTag) { + newLists[tagName] = this._state.lists[tagName].filter((room) => { + return room.roomId !== updatedRoom.roomId; + }); + } else { + newLists[tagName] = this._state.lists[tagName]; + } + }); + + /// XXX: RoomSubList sorts by data on the room object. We + /// should sort in advance and incrementally insert new rooms + /// instead of resorting every time. + if (metaData) { + updatedRoom.tags[newTag] = metaData; + } + + newLists[newTag].push(updatedRoom); + + this._setState({ + lists: newLists, + }); + } + + _updateRoomLists(updatedRoom) { + const roomTags = Object.keys(updatedRoom.tags); + + const newLists = {}; + + // Removal of the updatedRoom from tags it no longer has + Object.keys(this._state.lists).forEach((tagName) => { + newLists[tagName] = this._state.lists[tagName].filter((room) => { + return room.roomId !== updatedRoom.roomId || roomTags.includes(tagName); + }); + }); + + roomTags.forEach((tagName) => { + if (newLists[tagName].includes(updatedRoom)) return; + newLists[tagName].push(updatedRoom); + }); + + this._setState({ + lists: newLists, + }); + } + + _generateRoomLists(matrixClient) { + const lists = { + "im.vector.fake.invite": [], + "m.favourite": [], + "im.vector.fake.recent": [], + "im.vector.fake.direct": [], + "m.lowpriority": [], + "im.vector.fake.archived": [], + }; + + const dmRoomMap = DMRoomMap.shared(); + + matrixClient.getRooms().forEach((room, index) => { + const me = room.getMember(matrixClient.credentials.userId); + if (!me) return; + + if (me.membership == "invite") { + lists["im.vector.fake.invite"].push(room); + } else if (me.membership == "join" || me.membership === "ban" || + (me.membership === "leave" && me.events.member.getSender() !== me.events.member.getStateKey())) { + // Used to split rooms via tags + const tagNames = Object.keys(room.tags); + if (tagNames.length) { + for (let i = 0; i < tagNames.length; i++) { + const tagName = tagNames[i]; + lists[tagName] = lists[tagName] || []; + lists[tagName].push(room); + } + } else if (dmRoomMap.getUserIdForRoomId(room.roomId)) { + // "Direct Message" rooms (that we're still in and that aren't otherwise tagged) + lists["im.vector.fake.direct"].push(room); + } else { + lists["im.vector.fake.recent"].push(room); + } + } else if (me.membership === "leave") { + lists["im.vector.fake.archived"].push(room); + } else { + console.error("unrecognised membership: " + me.membership + " - this should never happen"); + } + }); + + this._setState({ + lists, + ready: true, // Ready to receive updates via Room.tags events + }); + } + + getRoomLists() { + return this._state.lists; + } +} + +if (global.singletonRoomListStore === undefined) { + global.singletonRoomListStore = new RoomListStore(); +} +export default global.singletonRoomListStore; From 73e3a594ac20dece69ff3f9b3aa644153703dca4 Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Thu, 25 Jan 2018 22:52:19 +0100 Subject: [PATCH 05/38] Prevent TagTiles from being dragged into other droppables for the time being at least. --- src/components/structures/TagPanel.js | 5 ++++- src/components/views/elements/DNDTagTile.js | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 8c790edc03..c843c08ce9 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -95,7 +95,10 @@ const TagPanel = React.createClass({ />; }); return
- + { (provided, snapshot) => (
{ (provided, snapshot) => (
From 4820a195ab1d75f318030c4313309ab319d23a29 Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Fri, 26 Jan 2018 09:15:03 +0100 Subject: [PATCH 06/38] Remove logging --- src/stores/RoomListStore.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index 16902ef471..69d957f074 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -46,7 +46,6 @@ class RoomListStore extends Store { _setState(newState) { this._state = Object.assign(this._state, newState); - console.info(this._state); this.__emitChange(); } From 330ce0f02e31a7be7195624718fb3caf5ad1d11a Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Mon, 5 Feb 2018 17:29:22 +0000 Subject: [PATCH 07/38] On failure, regenerate state from sdk Instead of using history, which could be unpredictable --- src/stores/RoomListStore.js | 33 +++++++++++---------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index 69d957f074..e48eacaa6a 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -26,7 +26,6 @@ class RoomListStore extends Store { super(dis); this._init(); - this._actionHistory = []; } _init() { @@ -57,14 +56,13 @@ class RoomListStore extends Store { break; } - this._generateRoomLists(payload.matrixClient); - this._actionHistory.unshift(payload); + this._matrixClient = payload.matrixClient; + this._generateRoomLists(); } break; case 'MatrixActions.Room.tags': { if (!this._state.ready) break; this._updateRoomLists(payload.room); - this._actionHistory.unshift(payload); } break; case 'RoomListActions.tagRoom.pending': { @@ -74,35 +72,22 @@ class RoomListStore extends Store { payload.request.newTag, payload.request.metaData, ); - this._actionHistory.unshift(payload); } break; case 'RoomListActions.tagRoom.failure': { - this._actionHistory = this._actionHistory.filter((action) => { - return action.asyncId !== payload.asyncId; - }); - - // don't duplicate history - const history = this._actionHistory.slice(0); - this._actionHistory = []; - this._reloadFromHistory(history); + // Reset state according to js-sdk + this._generateRoomLists(); } break; case 'on_logged_out': { // Reset state without pushing an update to the view, which generally assumes that // the matrix client isn't `null` and so causing a re-render will cause NPEs. this._init(); - this._actionHistory.unshift(payload); } break; } } - _reloadFromHistory(history) { - this._init(); - history.forEach((action) => this.__onDispatch(action)); - } - _updateRoomListsOptimistic(updatedRoom, oldTag, newTag, metaData) { const newLists = {}; @@ -153,7 +138,7 @@ class RoomListStore extends Store { }); } - _generateRoomLists(matrixClient) { + _generateRoomLists() { const lists = { "im.vector.fake.invite": [], "m.favourite": [], @@ -163,10 +148,14 @@ class RoomListStore extends Store { "im.vector.fake.archived": [], }; + const dmRoomMap = DMRoomMap.shared(); - matrixClient.getRooms().forEach((room, index) => { - const me = room.getMember(matrixClient.credentials.userId); + // If somehow we dispatched a RoomListActions.tagRoom.failure before a MatrixActions.sync + if (!this._matrixClient) return; + + this._matrixClient.getRooms().forEach((room, index) => { + const me = room.getMember(this._matrixClient.credentials.userId); if (!me) return; if (me.membership == "invite") { From 9982efbd8f61b75c4d4c688ce0124b47fb6c9deb Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Mon, 5 Feb 2018 18:06:29 +0000 Subject: [PATCH 08/38] Regenerate room lists when we get m.direct --- src/stores/RoomListStore.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index e48eacaa6a..6a9217eab4 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -65,6 +65,11 @@ class RoomListStore extends Store { this._updateRoomLists(payload.room); } break; + case 'MatrixActions.accountData': { + if (payload.event_type !== 'm.direct') break; + this._generateRoomLists(); + } + break; case 'RoomListActions.tagRoom.pending': { this._updateRoomListsOptimistic( payload.request.room, From c665c1170b419ec8b6e6b4b5fe7e7e06a792d0bd Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Mon, 5 Feb 2018 18:27:50 +0000 Subject: [PATCH 09/38] Regenerate room lists when we get RoomMember.membership --- src/actions/MatrixActionCreators.js | 5 +++++ src/stores/RoomListStore.js | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/actions/MatrixActionCreators.js b/src/actions/MatrixActionCreators.js index d9309d7c1c..f4d6c34ff5 100644 --- a/src/actions/MatrixActionCreators.js +++ b/src/actions/MatrixActionCreators.js @@ -66,6 +66,10 @@ function createRoomTagsAction(matrixClient, roomTagsEvent, room) { return { action: 'MatrixActions.Room.tags', room }; } +function createRoomMembershipAction(matrixClient, membershipEvent, member, oldMembership) { + return { action: 'MatrixActions.RoomMember.membership', member }; +} + /** * This object is responsible for dispatching actions when certain events are emitted by * the given MatrixClient. @@ -83,6 +87,7 @@ export default { this._addMatrixClientListener(matrixClient, 'sync', createSyncAction); this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction); + this._addMatrixClientListener(matrixClient, 'RoomMember.membership', createRoomMembershipAction); }, /** diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index 6a9217eab4..28ffee99d2 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -70,6 +70,11 @@ class RoomListStore extends Store { this._generateRoomLists(); } break; + case 'MatrixActions.RoomMember.membership': { + if (!this._matrixClient || payload.member.userId !== this._matrixClient.credentials.userId) break; + this._generateRoomLists(); + } + break; case 'RoomListActions.tagRoom.pending': { this._updateRoomListsOptimistic( payload.request.room, From feca1707f119bab11c836a810ec9566df7de6498 Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Tue, 6 Feb 2018 09:55:58 +0000 Subject: [PATCH 10/38] Remove a factor n complexity during room visibility calc --- src/components/views/rooms/RoomList.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index ad85beac12..269f04c963 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -352,6 +352,14 @@ module.exports = React.createClass({ const filteredLists = {}; + const isRoomVisible = { + // $roomId: true, + }; + + this._visibleRooms.forEach((r) => { + isRoomVisible[r.roomId] = true; + }); + Object.keys(lists).forEach((tagName) => { filteredLists[tagName] = lists[tagName].filter((taggedRoom) => { // Somewhat impossible, but guard against it anyway @@ -363,9 +371,7 @@ module.exports = React.createClass({ return; } - return this._visibleRooms.some((visibleRoom) => { - return visibleRoom.roomId === taggedRoom.roomId; - }); + return isRoomVisible[taggedRoom.roomId]; }); }); From b744dbaab7d7feb3008f144a369d7f7cb4309eea Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Tue, 6 Feb 2018 11:56:55 +0000 Subject: [PATCH 11/38] Handle setting a newTag without metaData metaData is actually the request body for the PUT that adds the tag so we need to send {} for e.g. m.lowpriority, which is not manually ordered. --- src/actions/RoomListActions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/RoomListActions.js b/src/actions/RoomListActions.js index 3e0ea53a33..5802dbcaed 100644 --- a/src/actions/RoomListActions.js +++ b/src/actions/RoomListActions.js @@ -114,7 +114,7 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, (hasChangedSubLists || metaData) ) { // Optimistic update of what will happen to the room tags - room.tags[newTag] = metaData; + room.tags[newTag] = metaData || {}; const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); From 8d0d0b43ffa6440bf98577c2246d6ec78fa3ab76 Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Tue, 6 Feb 2018 12:00:06 +0000 Subject: [PATCH 12/38] Handle first tag added/last tag removed This is a special case because untagged rooms should appear in im.vector.fake.recent and tagged rooms should not. --- src/stores/RoomListStore.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index 28ffee99d2..32aab47009 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -101,6 +101,16 @@ class RoomListStore extends Store { _updateRoomListsOptimistic(updatedRoom, oldTag, newTag, metaData) { const newLists = {}; + // Adding a tag to an untagged room - need to remove it from recents + if (newTag && Object.keys(updatedRoom.tags).length === 0) { + oldTag = 'im.vector.fake.recent'; + } + + // Removing a tag from a room with one tag left - need to add it to recents + if (oldTag && Object.keys(updatedRoom.tags).length === 1) { + newTag = 'im.vector.fake.recent'; + } + // Remove room from oldTag Object.keys(this._state.lists).forEach((tagName) => { if (tagName === oldTag) { From dd0e981d728e9cb9974ed49d0cc46b48f1dea7ae Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Tue, 6 Feb 2018 12:00:23 +0000 Subject: [PATCH 13/38] Handle indication from server that a room has no more tags --- src/stores/RoomListStore.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index 32aab47009..42c77d5237 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -153,6 +153,10 @@ class RoomListStore extends Store { newLists[tagName].push(updatedRoom); }); + if (roomTags.length === 0) { + newLists['im.vector.fake.recent'].unshift(updatedRoom); + } + this._setState({ lists: newLists, }); From 1ea6301ecaf7154acc4b862f2d7f7639ea32e711 Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Tue, 6 Feb 2018 14:25:33 +0000 Subject: [PATCH 14/38] Add index fix again This was changed on /develop to fix an issue where the incorrect index was being used in a condition to handle literal edge cases of dragging room tiles to start or end of an ordered sublist. --- src/actions/RoomListActions.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/actions/RoomListActions.js b/src/actions/RoomListActions.js index 5802dbcaed..f59e9953ee 100644 --- a/src/actions/RoomListActions.js +++ b/src/actions/RoomListActions.js @@ -56,10 +56,13 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, newTag === oldTag && oldIndex < newIndex ) ? 1 : 0; - const prevOrder = newIndex === 0 ? - 0 : newList[offset + newIndex - 1].tags[newTag].order; - const nextOrder = newIndex === newList.length ? - 1 : newList[offset + newIndex].tags[newTag].order; + const indexBefore = offset + newIndex - 1; + const indexAfter = offset + newIndex; + + const prevOrder = indexBefore <= 0 ? + 0 : newList[indexBefore].tags[newTag].order; + const nextOrder = indexAfter >= newList.length ? + 1 : newList[indexAfter].tags[newTag].order; metaData = { order: (prevOrder + nextOrder) / 2.0, From 7a594ce08dbcb1b0248ecfe39acab5b8ce60a607 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 8 Feb 2018 17:58:53 +0200 Subject: [PATCH 15/38] Add seconds to formatFullDate() Fixes vector-im/riot-web#6055 Signed-off-by: Tulir Asokan --- src/DateUtils.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/DateUtils.js b/src/DateUtils.js index 986525eec8..108697238c 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.js @@ -50,11 +50,15 @@ function pad(n) { return (n < 10 ? '0' : '') + n; } -function twelveHourTime(date) { +function twelveHourTime(date, showSeconds=false) { let hours = date.getHours() % 12; const minutes = pad(date.getMinutes()); const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM'); hours = hours ? hours : 12; // convert 0 -> 12 + if (showSeconds) { + const seconds = pad(date.getSeconds()); + return `${hours}:${minutes}:${seconds}${ampm}`; + } return `${hours}:${minutes}${ampm}`; } @@ -101,10 +105,17 @@ export function formatFullDate(date, showTwelveHour=false) { monthName: months[date.getMonth()], day: date.getDate(), fullYear: date.getFullYear(), - time: formatTime(date, showTwelveHour), + time: formatFullTime(date, showTwelveHour), }); } +export function formatFullTime(date, showTwelveHour=false) { + if (showTwelveHour) { + return twelveHourTime(date, true); + } + return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()); +} + export function formatTime(date, showTwelveHour=false) { if (showTwelveHour) { return twelveHourTime(date); From 21d70125e4e21d40320c793186527d96c9b9358c Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Thu, 8 Feb 2018 14:48:29 +0000 Subject: [PATCH 16/38] Dispatch MatrixActions synchronously Otherwise we risk blocking the dispatches on other work, and they do not need to be done asynchronously. This emerged as a bug where the room list appeared empty until MatrixActions.sync dispatches all occured in one big lump, well after the sync events being emitted by the js-sdk. --- src/actions/MatrixActionCreators.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/MatrixActionCreators.js b/src/actions/MatrixActionCreators.js index f4d6c34ff5..dbfe910533 100644 --- a/src/actions/MatrixActionCreators.js +++ b/src/actions/MatrixActionCreators.js @@ -101,7 +101,7 @@ export default { */ _addMatrixClientListener(matrixClient, eventName, actionCreator) { const listener = (...args) => { - dis.dispatch(actionCreator(matrixClient, ...args)); + dis.dispatch(actionCreator(matrixClient, ...args), true); }; matrixClient.on(eventName, listener); this._matrixClientListenersStop.push(() => { From 9b0df1914953223cbbf31b33231585d1ba6f5afb Mon Sep 17 00:00:00 2001 From: lukebarnard Date: Tue, 6 Feb 2018 15:15:47 +0000 Subject: [PATCH 17/38] Make RoomListStore aware of sub list orderings so that it can do optimistic updates of ordered lists. --- src/actions/RoomListActions.js | 5 +- src/stores/RoomListStore.js | 173 +++++++++++++++++++-------------- 2 files changed, 103 insertions(+), 75 deletions(-) diff --git a/src/actions/RoomListActions.js b/src/actions/RoomListActions.js index f59e9953ee..a92bd1ebaf 100644 --- a/src/actions/RoomListActions.js +++ b/src/actions/RoomListActions.js @@ -116,8 +116,9 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, if (newTag && newTag !== 'im.vector.fake.direct' && (hasChangedSubLists || metaData) ) { - // Optimistic update of what will happen to the room tags - room.tags[newTag] = metaData || {}; + // metaData is the body of the PUT to set the tag, so it must + // at least be an empty object. + metaData = metaData || {}; const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index 42c77d5237..193784811c 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -16,6 +16,7 @@ limitations under the License. import {Store} from 'flux/utils'; import dis from '../dispatcher'; import DMRoomMap from '../utils/DMRoomMap'; +import Unread from '../Unread'; /** * A class for storing application state for categorising rooms in @@ -26,6 +27,8 @@ class RoomListStore extends Store { super(dis); this._init(); + this._getManualComparator = this._getManualComparator.bind(this); + this._recentsComparator = this._recentsComparator.bind(this); } _init() { @@ -62,7 +65,7 @@ class RoomListStore extends Store { break; case 'MatrixActions.Room.tags': { if (!this._state.ready) break; - this._updateRoomLists(payload.room); + this._generateRoomLists(); } break; case 'MatrixActions.accountData': { @@ -76,12 +79,7 @@ class RoomListStore extends Store { } break; case 'RoomListActions.tagRoom.pending': { - this._updateRoomListsOptimistic( - payload.request.room, - payload.request.oldTag, - payload.request.newTag, - payload.request.metaData, - ); + this._generateRoomLists(payload.request); } break; case 'RoomListActions.tagRoom.failure': { @@ -93,76 +91,13 @@ class RoomListStore extends Store { // Reset state without pushing an update to the view, which generally assumes that // the matrix client isn't `null` and so causing a re-render will cause NPEs. this._init(); + this._matrixClient = null; } break; } } - _updateRoomListsOptimistic(updatedRoom, oldTag, newTag, metaData) { - const newLists = {}; - - // Adding a tag to an untagged room - need to remove it from recents - if (newTag && Object.keys(updatedRoom.tags).length === 0) { - oldTag = 'im.vector.fake.recent'; - } - - // Removing a tag from a room with one tag left - need to add it to recents - if (oldTag && Object.keys(updatedRoom.tags).length === 1) { - newTag = 'im.vector.fake.recent'; - } - - // Remove room from oldTag - Object.keys(this._state.lists).forEach((tagName) => { - if (tagName === oldTag) { - newLists[tagName] = this._state.lists[tagName].filter((room) => { - return room.roomId !== updatedRoom.roomId; - }); - } else { - newLists[tagName] = this._state.lists[tagName]; - } - }); - - /// XXX: RoomSubList sorts by data on the room object. We - /// should sort in advance and incrementally insert new rooms - /// instead of resorting every time. - if (metaData) { - updatedRoom.tags[newTag] = metaData; - } - - newLists[newTag].push(updatedRoom); - - this._setState({ - lists: newLists, - }); - } - - _updateRoomLists(updatedRoom) { - const roomTags = Object.keys(updatedRoom.tags); - - const newLists = {}; - - // Removal of the updatedRoom from tags it no longer has - Object.keys(this._state.lists).forEach((tagName) => { - newLists[tagName] = this._state.lists[tagName].filter((room) => { - return room.roomId !== updatedRoom.roomId || roomTags.includes(tagName); - }); - }); - - roomTags.forEach((tagName) => { - if (newLists[tagName].includes(updatedRoom)) return; - newLists[tagName].push(updatedRoom); - }); - - if (roomTags.length === 0) { - newLists['im.vector.fake.recent'].unshift(updatedRoom); - } - - this._setState({ - lists: newLists, - }); - } - - _generateRoomLists() { + _generateRoomLists(optimisticRequest) { const lists = { "im.vector.fake.invite": [], "m.favourite": [], @@ -187,7 +122,19 @@ class RoomListStore extends Store { } else if (me.membership == "join" || me.membership === "ban" || (me.membership === "leave" && me.events.member.getSender() !== me.events.member.getStateKey())) { // Used to split rooms via tags - const tagNames = Object.keys(room.tags); + let tagNames = Object.keys(room.tags); + + if (optimisticRequest && optimisticRequest.room === room) { + // Remove old tag + tagNames = tagNames.filter((tagName) => tagName !== optimisticRequest.oldTag); + // Add new tag + if (optimisticRequest.newTag && + !tagNames.includes(optimisticRequest.newTag) + ) { + tagNames.push(optimisticRequest.newTag); + } + } + if (tagNames.length) { for (let i = 0; i < tagNames.length; i++) { const tagName = tagNames[i]; @@ -207,12 +154,92 @@ class RoomListStore extends Store { } }); + const listOrders = { + "manual": [ + "m.favourite", + ], + "recent": [ + "im.vector.fake.invite", + "im.vector.fake.recent", + "im.vector.fake.direct", + "m.lowpriority", + "im.vector.fake.archived", + ], + }; + + Object.keys(listOrders).forEach((order) => { + listOrders[order].forEach((listKey) => { + let comparator; + switch (order) { + case "manual": + comparator = this._getManualComparator(listKey, optimisticRequest); + break; + case "recent": + comparator = this._recentsComparator; + break; + } + lists[listKey].sort(comparator); + }); + }); + this._setState({ lists, ready: true, // Ready to receive updates via Room.tags events }); } + _tsOfNewestEvent(room) { + for (let i = room.timeline.length - 1; i >= 0; --i) { + const ev = room.timeline[i]; + if (ev.getTs() && + (Unread.eventTriggersUnreadCount(ev) || + (ev.getSender() === this._matrixClient.credentials.userId)) + ) { + return ev.getTs(); + } + } + + // we might only have events that don't trigger the unread indicator, + // in which case use the oldest event even if normally it wouldn't count. + // This is better than just assuming the last event was forever ago. + if (room.timeline.length && room.timeline[0].getTs()) { + return room.timeline[0].getTs(); + } else { + return Number.MAX_SAFE_INTEGER; + } + } + + _recentsComparator(roomA, roomB) { + return this._tsOfNewestEvent(roomB) - this._tsOfNewestEvent(roomA); + } + + _lexicographicalComparator(roomA, roomB) { + return roomA.name > roomB.name ? 1 : -1; + } + + _getManualComparator(tagName, optimisticRequest) { + return (roomA, roomB) => { + let metaA = roomA.tags[tagName]; + let metaB = roomB.tags[tagName]; + + if (optimisticRequest && roomA === optimisticRequest.room) metaA = optimisticRequest.metaData; + if (optimisticRequest && roomB === optimisticRequest.room) metaB = optimisticRequest.metaData; + + // Make sure the room tag has an order element, if not set it to be the bottom + const a = metaA.order; + const b = metaB.order; + + // Order undefined room tag orders to the bottom + if (a === undefined && b !== undefined) { + return 1; + } else if (a !== undefined && b === undefined) { + return -1; + } + + return a == b ? this._lexicographicalComparator(roomA, roomB) : ( a > b ? 1 : -1); + }; + } + getRoomLists() { return this._state.lists; } From c5da1015fe95a5d30971cd05164e1fd89a3898d8 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Thu, 8 Feb 2018 17:47:36 +0000 Subject: [PATCH 18/38] Do not truncate autocompleted users in composer so that disambiguation is possible at a glance. Fixes https://github.com/vector-im/riot-web/issues/6024 --- src/autocomplete/UserProvider.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index fefe77f6cd..bceec3f144 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -158,7 +158,7 @@ export default class UserProvider extends AutocompleteProvider { } renderCompletions(completions: [React.Component]): ?React.Component { - return
+ return
{ completions }
; } From e3f68f12c859e23342b6f7266338d9feb2655f50 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Mon, 12 Feb 2018 18:01:08 +0000 Subject: [PATCH 19/38] Add context menu to TagTile With two options: View Community and Remove, which removes the tag from the panel. --- src/actions/TagOrderActions.js | 47 ++++++++++++++++++++++++ src/components/views/elements/TagTile.js | 35 ++++++++++++++++++ src/i18n/strings/en_EN.json | 2 +- src/stores/TagOrderStore.js | 20 +++++++++- 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/actions/TagOrderActions.js b/src/actions/TagOrderActions.js index dd4df6a9d4..3504adb09b 100644 --- a/src/actions/TagOrderActions.js +++ b/src/actions/TagOrderActions.js @@ -56,4 +56,51 @@ TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) { }); }; +/** + * Creates an action thunk that will do an asynchronous request to + * label a tag as removed in im.vector.web.tag_ordering account data. + * + * The reason this is implemented with new state `removedTags` is that + * we incrementally and initially populate `tags` with groups that + * have been joined. If we remove a group from `tags`, it will just + * get added (as it looks like a group we've recently joined). + * + * NB: If we ever support adding of tags (which is planned), we should + * take special care to remove the tag from `removedTags` when we add + * it. + * + * @param {MatrixClient} matrixClient the matrix client to set the + * account data on. + * @param {string} tag the tag to remove. + * @returns {function} an action thunk that will dispatch actions + * indicating the status of the request. + * @see asyncAction + */ +TagOrderActions.removeTag = function(matrixClient, tag) { + // Don't change tags, just removedTags + const tags = TagOrderStore.getOrderedTags(); + const removedTags = TagOrderStore.getRemovedTagsAccountData() || []; + + if (removedTags.includes(tag)) { + // Return a thunk that doesn't do anything, we don't even need + // an asynchronous action here, the tag is already removed. + return () => {}; + } + + removedTags.push(tag); + + const storeId = TagOrderStore.getStoreId(); + + return asyncAction('TagOrderActions.removeTag', () => { + Analytics.trackEvent('TagOrderActions', 'removeTag'); + return matrixClient.setAccountData( + 'im.vector.web.tag_ordering', + {tags, removedTags, _storeId: storeId}, + ); + }, () => { + // For an optimistic update + return {removedTags}; + }); +}; + export default TagOrderActions; diff --git a/src/components/views/elements/TagTile.js b/src/components/views/elements/TagTile.js index f52f758cc0..8d801d986d 100644 --- a/src/components/views/elements/TagTile.js +++ b/src/components/views/elements/TagTile.js @@ -21,6 +21,7 @@ import { MatrixClient } from 'matrix-js-sdk'; import sdk from '../../../index'; import dis from '../../../dispatcher'; import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard'; +import ContextualMenu from '../../structures/ContextualMenu'; import FlairStore from '../../../stores/FlairStore'; @@ -81,6 +82,35 @@ export default React.createClass({ }); }, + onContextButtonClick: function(e) { + e.preventDefault(); + e.stopPropagation(); + + // Hide the (...) immediately + this.setState({ hover: false }); + + const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu'); + const elementRect = e.target.getBoundingClientRect(); + + // The window X and Y offsets are to adjust position when zoomed in to page + const x = elementRect.right + window.pageXOffset + 3; + const chevronOffset = 12; + let y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset); + y = y - (chevronOffset + 8); // where 8 is half the height of the chevron + + const self = this; + ContextualMenu.createMenu(TagTileContextMenu, { + chevronOffset: chevronOffset, + left: x, + top: y, + tag: this.props.tag, + onFinished: function() { + self.setState({ menuDisplayed: false }); + }, + }); + this.setState({ menuDisplayed: true }); + }, + onMouseOver: function() { this.setState({hover: true}); }, @@ -109,10 +139,15 @@ export default React.createClass({ const tip = this.state.hover ? :
; + const contextButton = this.state.hover || this.state.menuDisplayed ? +
+ { "\u00B7\u00B7\u00B7" } +
:
; return
{ tip } + { contextButton }
; }, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6139ac2a91..3cfbe24122 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -500,8 +500,8 @@ "Download %(text)s": "Download %(text)s", "Invalid file%(extra)s": "Invalid file%(extra)s", "Error decrypting image": "Error decrypting image", - "Image '%(Body)s' cannot be displayed.": "Image '%(Body)s' cannot be displayed.", "This image cannot be displayed.": "This image cannot be displayed.", + "Image '%(Body)s' cannot be displayed.": "Image '%(Body)s' cannot be displayed.", "Error decrypting video": "Error decrypting video", "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s", "%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.", diff --git a/src/stores/TagOrderStore.js b/src/stores/TagOrderStore.js index 69b22797fb..3ec751d075 100644 --- a/src/stores/TagOrderStore.js +++ b/src/stores/TagOrderStore.js @@ -55,6 +55,7 @@ class TagOrderStore extends Store { const tagOrderingEventContent = tagOrderingEvent ? tagOrderingEvent.getContent() : {}; this._setState({ orderedTagsAccountData: tagOrderingEventContent.tags || null, + removedTagsAccountData: tagOrderingEventContent.removedTags || null, hasSynced: true, }); this._updateOrderedTags(); @@ -70,6 +71,7 @@ class TagOrderStore extends Store { this._setState({ orderedTagsAccountData: payload.event_content ? payload.event_content.tags : null, + removedTagsAccountData: payload.event_content ? payload.event_content.removedTags : null, }); this._updateOrderedTags(); break; @@ -90,6 +92,14 @@ class TagOrderStore extends Store { }); break; } + case 'TagOrderActions.removeTag.pending': { + // Optimistic update of a removed tag + this._setState({ + removedTagsAccountData: payload.request.removedTags, + }); + this._updateOrderedTags(); + break; + } case 'select_tag': { let newTags = []; // Shift-click semantics @@ -165,13 +175,15 @@ class TagOrderStore extends Store { _mergeGroupsAndTags() { const groupIds = this._state.joinedGroupIds || []; const tags = this._state.orderedTagsAccountData || []; + const removedTags = this._state.removedTagsAccountData || []; + const tagsToKeep = tags.filter( - (t) => t[0] !== '+' || groupIds.includes(t), + (t) => (t[0] !== '+' || groupIds.includes(t)) && !removedTags.includes(t), ); const groupIdsToAdd = groupIds.filter( - (groupId) => !tags.includes(groupId), + (groupId) => !tags.includes(groupId) && !removedTags.includes(groupId), ); return tagsToKeep.concat(groupIdsToAdd); @@ -181,6 +193,10 @@ class TagOrderStore extends Store { return this._state.orderedTags; } + getRemovedTagsAccountData() { + return this._state.removedTagsAccountData; + } + getStoreId() { // Generate a random ID to prevent this store from clobbering its // state with redundant remote echos. From 7a4c1994c327e4616a801c0a5d4aa1495fbdbb67 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Mon, 12 Feb 2018 18:35:13 +0000 Subject: [PATCH 20/38] Use Boolean() instead of assuming filter is based on truthiness --- src/components/views/rooms/RoomList.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 269f04c963..4a491c8a03 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -371,7 +371,7 @@ module.exports = React.createClass({ return; } - return isRoomVisible[taggedRoom.roomId]; + return Boolean(isRoomVisible[taggedRoom.roomId]); }); }); From 3eeef064bf5e7abaca367f2c62dce174d9707644 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Mon, 12 Feb 2018 18:37:54 +0000 Subject: [PATCH 21/38] Remove unused asyncId --- src/actions/actionCreators.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/actions/actionCreators.js b/src/actions/actionCreators.js index 697930414c..967ce609e7 100644 --- a/src/actions/actionCreators.js +++ b/src/actions/actionCreators.js @@ -33,28 +33,25 @@ limitations under the License. * `${id}.failure`. * * The shape of each are: - * { action: '${id}.pending', request, asyncId } - * { action: '${id}.success', result, asyncId } - * { action: '${id}.failure', err, asyncId } + * { action: '${id}.pending', request } + * { action: '${id}.success', result } + * { action: '${id}.failure', err } * - * where `request` is returned by `pendingFn`, result - * is the result of the promise returned by `fn` and - * `asyncId` is a unique ID for each dispatch of the - * asynchronous action. + * where `request` is returned by `pendingFn` and + * result is the result of the promise returned by + * `fn`. */ export function asyncAction(id, fn, pendingFn) { return (dispatch) => { - const asyncId = Math.random().toString(16).slice(2, 10); dispatch({ action: id + '.pending', request: typeof pendingFn === 'function' ? pendingFn() : undefined, - asyncId, }); fn().then((result) => { - dispatch({action: id + '.success', result, asyncId}); + dispatch({action: id + '.success', result}); }).catch((err) => { - dispatch({action: id + '.failure', err, asyncId}); + dispatch({action: id + '.failure', err}); }); }; } From 322012cf889c00fe5b2bc790a01c52e6ca1e689e Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Mon, 12 Feb 2018 18:46:36 +0000 Subject: [PATCH 22/38] Add comment to explain hacky optimism --- src/stores/RoomListStore.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index 193784811c..b71e1c5cc1 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -79,6 +79,11 @@ class RoomListStore extends Store { } break; case 'RoomListActions.tagRoom.pending': { + // XXX: we only show one optimistic update at any one time. + // Ideally we should be making a list of in-flight requests + // that are backed by transaction IDs. Until the js-sdk + // supports this, we're stuck with only being able to use + // the most recent optimistic update. this._generateRoomLists(payload.request); } break; From 6d3634a06c3b0a8789200c89c19feec8630f90ba Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Tue, 13 Feb 2018 09:44:00 +0000 Subject: [PATCH 23/38] Move groups button to TagPanel --- src/components/structures/TagPanel.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 49a7a4020a..f10936e802 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -111,8 +111,7 @@ const TagPanel = React.createClass({ }, render() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const TintableSvg = sdk.getComponent('elements.TintableSvg'); + const GroupsButton = sdk.getComponent('elements.GroupsButton'); const DNDTagTile = sdk.getComponent('elements.DNDTagTile'); const tags = this.state.orderedTags.map((tag, index) => { @@ -142,9 +141,9 @@ const TagPanel = React.createClass({ ) } - - - +
+ +
; }, }); From 493116b17e57d56036a896ef60ab9886ebb97112 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Tue, 13 Feb 2018 11:43:22 +0000 Subject: [PATCH 24/38] Give the login page its spinner back --- src/components/structures/login/Login.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index 5042ca1fd0..7f4aa0325a 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -431,10 +431,10 @@ module.exports = React.createClass({ // FIXME: remove status.im theme tweaks const theme = SettingsStore.getValue("theme"); if (theme !== "status") { - header =

{ _t('Sign in') }

; + header =

{ _t('Sign in') } { loader }

; } else { if (!this.state.errorText) { - header =

{ _t('Sign in to get started') }

; + header =

{ _t('Sign in to get started') } { loader }

; } } From 8377abcd1987a34cecbb3b363f807731f5dd861e Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Tue, 13 Feb 2018 11:49:59 +0000 Subject: [PATCH 25/38] Store component state for editors to prevent a forceUpdate from /sync causing the editors to revert before the user had a chance to hit "Save". Part of fixing https://github.com/vector-im/riot-web/issues/6019 --- src/components/views/rooms/RoomNameEditor.js | 21 ++++++++++++++--- src/components/views/rooms/RoomTopicEditor.js | 23 +++++++++++++++---- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/components/views/rooms/RoomNameEditor.js b/src/components/views/rooms/RoomNameEditor.js index 5c224d79c0..d073a0be25 100644 --- a/src/components/views/rooms/RoomNameEditor.js +++ b/src/components/views/rooms/RoomNameEditor.js @@ -29,13 +29,21 @@ module.exports = React.createClass({ room: PropTypes.object.isRequired, }, + getInitialState: function() { + return { + name: null, + }; + }, + componentWillMount: function() { const room = this.props.room; const name = room.currentState.getStateEvents('m.room.name', ''); const myId = MatrixClientPeg.get().credentials.userId; const defaultName = room.getDefaultRoomName(myId); - this._initialName = name ? name.getContent().name : ''; + this.setState({ + name: name ? name.getContent().name : '', + }); this._placeholderName = _t("Unnamed Room"); if (defaultName && defaultName !== 'Empty room') { // default name from JS SDK, needs no translation as we don't ever show it. @@ -44,7 +52,13 @@ module.exports = React.createClass({ }, getRoomName: function() { - return this.refs.editor.getValue(); + return this.state.name; + }, + + _onValueChanged: function(value, shouldSubmit) { + this.setState({ + name: value, + }); }, render: function() { @@ -57,7 +71,8 @@ module.exports = React.createClass({ placeholderClassName="mx_RoomHeader_placeholder" placeholder={this._placeholderName} blurToCancel={false} - initialValue={this._initialName} + initialValue={this.state.name} + onValueChanged={this._onValueChanged} dir="auto" />
); diff --git a/src/components/views/rooms/RoomTopicEditor.js b/src/components/views/rooms/RoomTopicEditor.js index 8f950d625c..7ad02f264c 100644 --- a/src/components/views/rooms/RoomTopicEditor.js +++ b/src/components/views/rooms/RoomTopicEditor.js @@ -28,26 +28,41 @@ module.exports = React.createClass({ room: PropTypes.object.isRequired, }, + getInitialState: function() { + return { + topic: null, + }; + }, + componentWillMount: function() { const room = this.props.room; const topic = room.currentState.getStateEvents('m.room.topic', ''); - this._initialTopic = topic ? topic.getContent().topic : ''; + this.setState({ + topic: topic ? topic.getContent().topic : '', + }); }, getTopic: function() { - return this.refs.editor.getValue(); + return this.state.topic; + }, + + _onValueChanged: function(value) { + this.setState({ + topic: value, + }); }, render: function() { const EditableText = sdk.getComponent("elements.EditableText"); return ( - ); }, From 36e8bf1f20661c380bec1ad5a2337f914480cd0a Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Tue, 13 Feb 2018 14:13:47 +0000 Subject: [PATCH 26/38] Change CSS class for message panel spinner to stop scrollbars appearing when we - jump to a message or, - permalink that is to an not paginated in event --- src/components/structures/TimelinePanel.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 4ade78af85..12f745146e 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -1121,9 +1121,9 @@ var TimelinePanel = React.createClass({ // exist. if (this.state.timelineLoading) { return ( -
- -
+
+ +
); } From 5af560f625ef48000063c1a5b9ee3b060bf6d46e Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Tue, 13 Feb 2018 14:43:34 +0000 Subject: [PATCH 27/38] Make removedTags a Set for perf --- src/stores/TagOrderStore.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/stores/TagOrderStore.js b/src/stores/TagOrderStore.js index 3ec751d075..78e4a6e95d 100644 --- a/src/stores/TagOrderStore.js +++ b/src/stores/TagOrderStore.js @@ -175,15 +175,15 @@ class TagOrderStore extends Store { _mergeGroupsAndTags() { const groupIds = this._state.joinedGroupIds || []; const tags = this._state.orderedTagsAccountData || []; - const removedTags = this._state.removedTagsAccountData || []; + const removedTags = new Set(this._state.removedTagsAccountData || []); const tagsToKeep = tags.filter( - (t) => (t[0] !== '+' || groupIds.includes(t)) && !removedTags.includes(t), + (t) => (t[0] !== '+' || groupIds.includes(t)) && !removedTags.has(t), ); const groupIdsToAdd = groupIds.filter( - (groupId) => !tags.includes(groupId) && !removedTags.includes(groupId), + (groupId) => !tags.includes(groupId) && !removedTags.has(groupId), ); return tagsToKeep.concat(groupIdsToAdd); From f16bc93fee1c46dddf90f323d82e1646fab4b5a4 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Tue, 13 Feb 2018 16:09:17 +0000 Subject: [PATCH 28/38] If a tag is unrecognised, assume manual ordering (as we did previously) Fixes https://github.com/vector-im/riot-web/issues/6135 --- src/stores/RoomListStore.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index b71e1c5cc1..693275952b 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -176,12 +176,13 @@ class RoomListStore extends Store { listOrders[order].forEach((listKey) => { let comparator; switch (order) { - case "manual": - comparator = this._getManualComparator(listKey, optimisticRequest); - break; case "recent": comparator = this._recentsComparator; break; + case "manual": + default: + comparator = this._getManualComparator(listKey, optimisticRequest); + break; } lists[listKey].sort(comparator); }); From 3020c8cd94a08210070a7633bfa35a93a049c367 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 14 Feb 2018 11:23:29 +0000 Subject: [PATCH 29/38] Fix custom tags not being ordered manually Actually fixes vector-im/riot-web#6135 unlike #1748, which incorrectly assumed that custom tags would be included in listOrders. This fix makes sure that the `default` case in the `switch` is actually used. --- src/stores/RoomListStore.js | 42 ++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index 693275952b..fdd9ca6692 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -160,32 +160,26 @@ class RoomListStore extends Store { }); const listOrders = { - "manual": [ - "m.favourite", - ], - "recent": [ - "im.vector.fake.invite", - "im.vector.fake.recent", - "im.vector.fake.direct", - "m.lowpriority", - "im.vector.fake.archived", - ], + "m.favourite": "manual", + "im.vector.fake.invite": "recent", + "im.vector.fake.recent": "recent", + "im.vector.fake.direct": "recent", + "m.lowpriority": "recent", + "im.vector.fake.archived": "recent", }; - Object.keys(listOrders).forEach((order) => { - listOrders[order].forEach((listKey) => { - let comparator; - switch (order) { - case "recent": - comparator = this._recentsComparator; - break; - case "manual": - default: - comparator = this._getManualComparator(listKey, optimisticRequest); - break; - } - lists[listKey].sort(comparator); - }); + Object.keys(lists).forEach((listKey) => { + let comparator; + switch (listOrders[listKey]) { + case "recent": + comparator = this._recentsComparator; + break; + case "manual": + default: + comparator = this._getManualComparator(listKey, optimisticRequest); + break; + } + lists[listKey].sort(comparator); }); this._setState({ From db4f0cb0bffc091df477899f00eded9c265bedc7 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 14 Feb 2018 16:40:24 +0000 Subject: [PATCH 30/38] Handle adding previously removed tags --- src/actions/TagOrderActions.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/actions/TagOrderActions.js b/src/actions/TagOrderActions.js index 3504adb09b..38bada2228 100644 --- a/src/actions/TagOrderActions.js +++ b/src/actions/TagOrderActions.js @@ -35,6 +35,7 @@ const TagOrderActions = {}; TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) { // Only commit tags if the state is ready, i.e. not null let tags = TagOrderStore.getOrderedTags(); + let removedTags = TagOrderStore.getRemovedTagsAccountData(); if (!tags) { return; } @@ -42,17 +43,19 @@ TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) { tags = tags.filter((t) => t !== tag); tags = [...tags.slice(0, destinationIx), tag, ...tags.slice(destinationIx)]; + removedTags = removedTags.filter((t) => t !== tag); + const storeId = TagOrderStore.getStoreId(); return asyncAction('TagOrderActions.moveTag', () => { Analytics.trackEvent('TagOrderActions', 'commitTagOrdering'); return matrixClient.setAccountData( 'im.vector.web.tag_ordering', - {tags, _storeId: storeId}, + {tags, removedTags, _storeId: storeId}, ); }, () => { // For an optimistic update - return {tags}; + return {tags, removedTags}; }); }; From b626420eb93e4f25333cd5c98758d7d3cc586265 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 14 Feb 2018 16:40:58 +0000 Subject: [PATCH 31/38] Move DND context to LoggedInView so that we can drag things from any part of the logged in app to another. (Specifically GroupView and TagPanel). --- src/components/structures/LoggedInView.js | 70 +++++++++++++++++++---- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index d7fe699156..f6bbfd247b 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -19,6 +19,7 @@ limitations under the License. import * as Matrix from 'matrix-js-sdk'; import React from 'react'; import PropTypes from 'prop-types'; +import { DragDropContext } from 'react-beautiful-dnd'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; import Notifier from '../../Notifier'; @@ -30,6 +31,9 @@ import sessionStore from '../../stores/SessionStore'; import MatrixClientPeg from '../../MatrixClientPeg'; import SettingsStore from "../../settings/SettingsStore"; +import TagOrderActions from '../../actions/TagOrderActions'; +import RoomListActions from '../../actions/RoomListActions'; + /** * This is what our MatrixChat shows when we are logged in. The precise view is * determined by the page_type property. @@ -207,6 +211,50 @@ const LoggedInView = React.createClass({ } }, + _onDragEnd: function(result) { + // Dragged to an invalid destination, not onto a droppable + if (!result.destination) { + return; + } + + const dest = result.destination.droppableId; + + if (dest === 'tag-panel-droppable') { + // Could be "GroupTile +groupId:domain" + const draggableId = result.draggableId.split(' ').pop(); + + // Dispatch synchronously so that the TagPanel receives an + // optimistic update from TagOrderStore before the previous + // state is shown. + dis.dispatch(TagOrderActions.moveTag( + this._matrixClient, + draggableId, + result.destination.index, + ), true); + } else if (dest.startsWith('room-sub-list-droppable_')) { + this._onRoomTileEndDrag(result); + } + }, + + _onRoomTileEndDrag: function(result) { + let newTag = result.destination.droppableId.split('_')[1]; + let prevTag = result.source.droppableId.split('_')[1]; + if (newTag === 'undefined') newTag = undefined; + if (prevTag === 'undefined') prevTag = undefined; + + const roomId = result.draggableId.split('_')[1]; + + const oldIndex = result.source.index; + const newIndex = result.destination.index; + + dis.dispatch(RoomListActions.tagRoom( + this._matrixClient, + this._matrixClient.getRoom(roomId), + prevTag, newTag, + oldIndex, newIndex, + ), true); + }, + render: function() { const LeftPanel = sdk.getComponent('structures.LeftPanel'); const RightPanel = sdk.getComponent('structures.RightPanel'); @@ -328,16 +376,18 @@ const LoggedInView = React.createClass({ return (
{ topBar } -
- -
- { page_element } -
- { right_panel } -
+ +
+ +
+ { page_element } +
+ { right_panel } +
+
); }, From 74c8a74e7d2106057b9c1e368914146c8d2ad8d3 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 14 Feb 2018 16:43:01 +0000 Subject: [PATCH 32/38] Add Droppable to GroupView to contain the GroupTiles as Draggables --- src/components/structures/MyGroups.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 22157beaca..4c9229a2ea 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -17,6 +17,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import GeminiScrollbar from 'react-gemini-scrollbar'; +import { Droppable } from 'react-beautiful-dnd'; import sdk from '../../index'; import { _t } from '../../languageHandler'; import dis from '../../dispatcher'; @@ -74,7 +75,13 @@ export default withMatrixClient(React.createClass({ contentHeader = groupNodes.length > 0 ?

{ _t('Your Communities') }

:
; content = groupNodes.length > 0 ? - { groupNodes } + + { (provided, snapshot) => ( +
+ { groupNodes } +
+ ) } +
:
{ _t( From 3850b552a5bddb753d21da3634d30914ed1f520f Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 14 Feb 2018 16:46:06 +0000 Subject: [PATCH 33/38] Make GroupTile avatar draggable --- src/components/views/groups/GroupTile.js | 32 +++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/components/views/groups/GroupTile.js b/src/components/views/groups/GroupTile.js index ce426a9b78..70947afa65 100644 --- a/src/components/views/groups/GroupTile.js +++ b/src/components/views/groups/GroupTile.js @@ -17,10 +17,12 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import {MatrixClient} from 'matrix-js-sdk'; +import { Draggable } from 'react-beautiful-dnd'; import sdk from '../../../index'; import dis from '../../../dispatcher'; import FlairStore from '../../../stores/FlairStore'; + const GroupTile = React.createClass({ displayName: 'GroupTile', @@ -78,9 +80,33 @@ const GroupTile = React.createClass({ profile.avatarUrl, avatarHeight, avatarHeight, "crop", ) : null; return -
- -
+ + { (provided, snapshot) => ( +
+
+
+ +
+
+ { /* Instead of a blank placeholder, use a copy of the avatar itself. */ } + { provided.placeholder ? +
+ +
: +
+ } +
+ ) } +
{ name }
{ descElement } From 389d96bc46047bf2791b2fc6bf8200d68f7673ed Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 14 Feb 2018 16:47:29 +0000 Subject: [PATCH 34/38] Use optimistic removedTagsAccountData state in TagOrderStore when receiving TagOrderActions.moveTag.pending, which now exposes this state. --- src/stores/TagOrderStore.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/stores/TagOrderStore.js b/src/stores/TagOrderStore.js index 78e4a6e95d..eef078d8da 100644 --- a/src/stores/TagOrderStore.js +++ b/src/stores/TagOrderStore.js @@ -89,6 +89,7 @@ class TagOrderStore extends Store { // Optimistic update of a moved tag this._setState({ orderedTags: payload.request.tags, + removedTagsAccountData: payload.request.removedTags, }); break; } From 3948ee8ca14d5f00f2a0163a692569f303be9811 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 14 Feb 2018 17:53:54 +0000 Subject: [PATCH 35/38] Give each GroupTile avatar its own droppable so that they can be dragged and dropped without interacting with each other, as they would do if GroupView contained one droppable to contain them all. --- src/components/structures/MyGroups.js | 13 ++---- src/components/views/groups/GroupTile.js | 56 +++++++++++++----------- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 4c9229a2ea..116607fb08 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -17,7 +17,6 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import GeminiScrollbar from 'react-gemini-scrollbar'; -import { Droppable } from 'react-beautiful-dnd'; import sdk from '../../index'; import { _t } from '../../languageHandler'; import dis from '../../dispatcher'; @@ -74,14 +73,10 @@ export default withMatrixClient(React.createClass({ }); contentHeader = groupNodes.length > 0 ?

{ _t('Your Communities') }

:
; content = groupNodes.length > 0 ? - - - { (provided, snapshot) => ( -
- { groupNodes } -
- ) } -
+ +
+ { groupNodes } +
:
{ _t( diff --git a/src/components/views/groups/GroupTile.js b/src/components/views/groups/GroupTile.js index 70947afa65..f1dbb75988 100644 --- a/src/components/views/groups/GroupTile.js +++ b/src/components/views/groups/GroupTile.js @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import {MatrixClient} from 'matrix-js-sdk'; -import { Draggable } from 'react-beautiful-dnd'; +import { Draggable, Droppable } from 'react-beautiful-dnd'; import sdk from '../../../index'; import dis from '../../../dispatcher'; import FlairStore from '../../../stores/FlairStore'; @@ -80,33 +80,39 @@ const GroupTile = React.createClass({ profile.avatarUrl, avatarHeight, avatarHeight, "crop", ) : null; return - - { (provided, snapshot) => ( -
-
+ { (droppableProvided, droppableSnapshot) => ( +
+ -
- -
-
- { /* Instead of a blank placeholder, use a copy of the avatar itself. */ } - { provided.placeholder ? -
- -
: -
- } + { (provided, snapshot) => ( +
+
+
+ +
+
+ { /* Instead of a blank placeholder, use a copy of the avatar itself. */ } + { provided.placeholder ? +
+ +
: +
+ } +
+ ) } +
) } - +
{ name }
{ descElement } From ceec40551908db8b35a0bd33d9fff6aa01b83b87 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 15 Feb 2018 11:23:00 +0000 Subject: [PATCH 36/38] Remove RoomListStore listener This caused the the RoomList component to leak (although in practice only accross logins because that's the only time it's unmounted) --- package.json | 2 +- src/components/views/rooms/RoomList.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index bb8db64d28..9d5013de28 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "require-json": "0.0.1", "rimraf": "^2.4.3", "sinon": "^1.17.3", - "source-map-loader": "^0.1.5", + "source-map-loader": "^0.1.6", "walk": "^2.3.9", "webpack": "^1.12.14" } diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 4a491c8a03..41a200420d 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -191,6 +191,10 @@ module.exports = React.createClass({ this._tagStoreToken.remove(); } + if (this._roomListStoreToken) { + this._roomListStoreToken.remove(); + } + if (this._groupStoreTokens.length > 0) { // NB: GroupStore is not a Flux.Store this._groupStoreTokens.forEach((token) => token.unregister()); From 44964e80a9c3f8974be669b9bf453375649ea986 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 15 Feb 2018 11:25:40 +0000 Subject: [PATCH 37/38] undo unintentional commit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9d5013de28..bb8db64d28 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "require-json": "0.0.1", "rimraf": "^2.4.3", "sinon": "^1.17.3", - "source-map-loader": "^0.1.6", + "source-map-loader": "^0.1.5", "walk": "^2.3.9", "webpack": "^1.12.14" } From 07b691a45d14fa817fe9d08d8ed1ecc2886b313c Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 15 Feb 2018 20:20:19 +0000 Subject: [PATCH 38/38] typo --- src/components/views/messages/MFileBody.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index c324c291e7..90efe24df3 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -82,7 +82,7 @@ Tinter.registerTintable(updateTintedDownloadImage); // downloaded. This limit does not seem to apply when the url is used as // the source attribute of an image tag. // -// Blob URLs are generated using window.URL.createObjectURL and unforuntately +// Blob URLs are generated using window.URL.createObjectURL and unfortunately // for our purposes they inherit the origin of the page that created them. // This means that any scripts that run when the URL is viewed will be able // to access local storage.