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;