Merge branch 'develop' into luke/feature-tag-panel-tile-context-menu

This commit is contained in:
Luke Barnard 2018-02-13 16:51:00 +00:00
commit a34fea8af8
8 changed files with 576 additions and 315 deletions

View file

@ -62,6 +62,14 @@ function createAccountDataAction(matrixClient, accountDataEvent) {
}; };
} }
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 * This object is responsible for dispatching actions when certain events are emitted by
* the given MatrixClient. * the given MatrixClient.
@ -78,6 +86,8 @@ export default {
start(matrixClient) { start(matrixClient) {
this._addMatrixClientListener(matrixClient, 'sync', createSyncAction); this._addMatrixClientListener(matrixClient, 'sync', createSyncAction);
this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction);
this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
this._addMatrixClientListener(matrixClient, 'RoomMember.membership', createRoomMembershipAction);
}, },
/** /**
@ -91,7 +101,7 @@ export default {
*/ */
_addMatrixClientListener(matrixClient, eventName, actionCreator) { _addMatrixClientListener(matrixClient, eventName, actionCreator) {
const listener = (...args) => { const listener = (...args) => {
dis.dispatch(actionCreator(matrixClient, ...args)); dis.dispatch(actionCreator(matrixClient, ...args), true);
}; };
matrixClient.on(eventName, listener); matrixClient.on(eventName, listener);
this._matrixClientListenersStop.push(() => { this._matrixClientListenersStop.push(() => {

View file

@ -0,0 +1,146 @@
/*
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 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,
};
}
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)
) {
// 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");
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;

View file

@ -31,6 +31,15 @@ limitations under the License.
* `${id}.pending` and either * `${id}.pending` and either
* `${id}.success` or * `${id}.success` or
* `${id}.failure`. * `${id}.failure`.
*
* The shape of each are:
* { action: '${id}.pending', request }
* { action: '${id}.success', result }
* { action: '${id}.failure', err }
*
* where `request` is returned by `pendingFn` and
* result is the result of the promise returned by
* `fn`.
*/ */
export function asyncAction(id, fn, pendingFn) { export function asyncAction(id, fn, pendingFn) {
return (dispatch) => { return (dispatch) => {

View file

@ -208,7 +208,6 @@ const LoggedInView = React.createClass({
}, },
render: function() { render: function() {
const TagPanel = sdk.getComponent('structures.TagPanel');
const LeftPanel = sdk.getComponent('structures.LeftPanel'); const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RightPanel = sdk.getComponent('structures.RightPanel'); const RightPanel = sdk.getComponent('structures.RightPanel');
const RoomView = sdk.getComponent('structures.RoomView'); const RoomView = sdk.getComponent('structures.RoomView');
@ -330,7 +329,6 @@ const LoggedInView = React.createClass({
<div className='mx_MatrixChat_wrapper'> <div className='mx_MatrixChat_wrapper'>
{ topBar } { topBar }
<div className={bodyClasses}> <div className={bodyClasses}>
{ SettingsStore.isFeatureEnabled("feature_tag_panel") ? <TagPanel /> : <div /> }
<LeftPanel <LeftPanel
collapsed={this.props.collapseLhs || false} collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled} disabled={this.props.leftDisabled}

View file

@ -20,12 +20,11 @@ import { MatrixClient } from 'matrix-js-sdk';
import TagOrderStore from '../../stores/TagOrderStore'; import TagOrderStore from '../../stores/TagOrderStore';
import GroupActions from '../../actions/GroupActions'; import GroupActions from '../../actions/GroupActions';
import TagOrderActions from '../../actions/TagOrderActions';
import sdk from '../../index'; import sdk from '../../index';
import dis from '../../dispatcher'; import dis from '../../dispatcher';
import { DragDropContext, Droppable } from 'react-beautiful-dnd'; import { Droppable } from 'react-beautiful-dnd';
const TagPanel = React.createClass({ const TagPanel = React.createClass({
displayName: 'TagPanel', displayName: 'TagPanel',
@ -94,25 +93,8 @@ const TagPanel = React.createClass({
dis.dispatch({action: 'view_create_group'}); 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() { render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const GroupsButton = sdk.getComponent('elements.GroupsButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const DNDTagTile = sdk.getComponent('elements.DNDTagTile'); const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const tags = this.state.orderedTags.map((tag, index) => { const tags = this.state.orderedTags.map((tag, index) => {
@ -124,8 +106,10 @@ const TagPanel = React.createClass({
/>; />;
}); });
return <div className="mx_TagPanel"> return <div className="mx_TagPanel">
<DragDropContext onDragEnd={this.onTagTileEndDrag}> <Droppable
<Droppable droppableId="tag-panel-droppable"> droppableId="tag-panel-droppable"
type="draggable-TagTile"
>
{ (provided, snapshot) => ( { (provided, snapshot) => (
<div <div
className="mx_TagPanel_tagTileContainer" className="mx_TagPanel_tagTileContainer"
@ -141,10 +125,9 @@ const TagPanel = React.createClass({
</div> </div>
) } ) }
</Droppable> </Droppable>
</DragDropContext> <div className="mx_TagPanel_createGroupButton">
<AccessibleButton className="mx_TagPanel_createGroupButton" onClick={this.onCreateGroupClick}> <GroupsButton tooltip={true} />
<TintableSvg src="img/icons-create-room.svg" width="25" height="25" /> </div>
</AccessibleButton>
</div>; </div>;
}, },
}); });

View file

@ -25,6 +25,7 @@ export default function DNDTagTile(props) {
key={props.tag} key={props.tag}
draggableId={props.tag} draggableId={props.tag}
index={props.index} index={props.index}
type="draggable-TagTile"
> >
{ (provided, snapshot) => ( { (provided, snapshot) => (
<div> <div>

View file

@ -18,7 +18,6 @@ limitations under the License.
'use strict'; 'use strict';
const React = require("react"); const React = require("react");
const ReactDOM = require("react-dom"); const ReactDOM = require("react-dom");
import { DragDropContext } from 'react-beautiful-dnd';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
const GeminiScrollbar = require('react-gemini-scrollbar'); const GeminiScrollbar = require('react-gemini-scrollbar');
@ -27,14 +26,13 @@ const CallHandler = require('../../../CallHandler');
const dis = require("../../../dispatcher"); const dis = require("../../../dispatcher");
const sdk = require('../../../index'); const sdk = require('../../../index');
const rate_limited_func = require('../../../ratelimitedfunc'); const rate_limited_func = require('../../../ratelimitedfunc');
const Rooms = require('../../../Rooms'); import * as Rooms from '../../../Rooms';
import DMRoomMap from '../../../utils/DMRoomMap'; import DMRoomMap from '../../../utils/DMRoomMap';
const Receipt = require('../../../utils/Receipt'); const Receipt = require('../../../utils/Receipt');
import TagOrderStore from '../../../stores/TagOrderStore'; import TagOrderStore from '../../../stores/TagOrderStore';
import RoomListStore from '../../../stores/RoomListStore';
import GroupStoreCache from '../../../stores/GroupStoreCache'; import GroupStoreCache from '../../../stores/GroupStoreCache';
import Modal from '../../../Modal';
const HIDE_CONFERENCE_CHANS = true; const HIDE_CONFERENCE_CHANS = true;
function phraseForSection(section) { function phraseForSection(section) {
@ -80,7 +78,6 @@ module.exports = React.createClass({
cli.on("deleteRoom", this.onDeleteRoom); cli.on("deleteRoom", this.onDeleteRoom);
cli.on("Room.timeline", this.onRoomTimeline); cli.on("Room.timeline", this.onRoomTimeline);
cli.on("Room.name", this.onRoomName); cli.on("Room.name", this.onRoomName);
cli.on("Room.tags", this.onRoomTags);
cli.on("Room.receipt", this.onRoomReceipt); cli.on("Room.receipt", this.onRoomReceipt);
cli.on("RoomState.events", this.onRoomStateEvents); cli.on("RoomState.events", this.onRoomStateEvents);
cli.on("RoomMember.name", this.onRoomMemberName); cli.on("RoomMember.name", this.onRoomMemberName);
@ -118,6 +115,10 @@ module.exports = React.createClass({
this.updateVisibleRooms(); this.updateVisibleRooms();
}); });
this._roomListStoreToken = RoomListStore.addListener(() => {
this._delayedRefreshRoomList();
});
this.refreshRoomList(); this.refreshRoomList();
// order of the sublists // order of the sublists
@ -178,7 +179,6 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("deleteRoom", this.onDeleteRoom); MatrixClientPeg.get().removeListener("deleteRoom", this.onDeleteRoom);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.tags", this.onRoomTags);
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName); MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName);
@ -251,10 +251,6 @@ module.exports = React.createClass({
this._delayedRefreshRoomList(); this._delayedRefreshRoomList();
}, },
onRoomTags: function(event, room) {
this._delayedRefreshRoomList();
},
onRoomStateEvents: function(ev, state) { onRoomStateEvents: function(ev, state) {
this._delayedRefreshRoomList(); this._delayedRefreshRoomList();
}, },
@ -278,106 +274,6 @@ module.exports = React.createClass({
this.forceUpdate(); 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 = 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 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;
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() { _delayedRefreshRoomList: new rate_limited_func(function() {
this.refreshRoomList(); this.refreshRoomList();
}, 500), }, 500),
@ -441,7 +337,7 @@ module.exports = React.createClass({
totalRooms += l.length; totalRooms += l.length;
} }
this.setState({ this.setState({
lists: this.getRoomLists(), lists,
totalRoomCount: totalRooms, totalRoomCount: totalRooms,
// Do this here so as to not render every time the selected tags // Do this here so as to not render every time the selected tags
// themselves change. // themselves change.
@ -452,70 +348,34 @@ module.exports = React.createClass({
}, },
getRoomLists: function() { getRoomLists: function() {
const lists = {}; const lists = RoomListStore.getRoomLists();
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 dmRoomMap = DMRoomMap.shared(); const filteredLists = {};
this._visibleRooms.forEach((room, index) => { const isRoomVisible = {
const me = room.getMember(MatrixClientPeg.get().credentials.userId); // $roomId: true,
if (!me) return; };
// console.log("room = " + room.name + ", me.membership = " + me.membership + this._visibleRooms.forEach((r) => {
// ", sender = " + me.events.member.getSender() + isRoomVisible[r.roomId] = true;
// ", 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);
}
} else if (me.membership === "leave") {
lists["im.vector.fake.archived"].push(room);
} else {
console.error("unrecognised membership: " + me.membership + " - this should never happen");
}
}); });
// we actually apply the sorting to this when receiving the prop in RoomSubLists. Object.keys(lists).forEach((tagName) => {
filteredLists[tagName] = lists[tagName].filter((taggedRoom) => {
// Somewhat impossible, but guard against it anyway
if (!taggedRoom) {
return;
}
const me = taggedRoom.getMember(MatrixClientPeg.get().credentials.userId);
if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(taggedRoom, me, this.props.ConferenceHandler)) {
return;
}
// we'll need this when we get to iterating through lists programatically - e.g. ctrl-shift-up/down return Boolean(isRoomVisible[taggedRoom.roomId]);
/* });
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() { _getScrollNode: function() {
@ -752,7 +612,6 @@ module.exports = React.createClass({
const self = this; const self = this;
return ( return (
<DragDropContext onDragEnd={this.onRoomTileEndDrag}>
<GeminiScrollbar className="mx_RoomList_scrollbar" <GeminiScrollbar className="mx_RoomList_scrollbar"
autoshow={true} onScroll={self._whenScrolling} ref="gemscroll"> autoshow={true} onScroll={self._whenScrolling} ref="gemscroll">
<div className="mx_RoomList"> <div className="mx_RoomList">
@ -861,7 +720,6 @@ module.exports = React.createClass({
onShowMoreRooms={self.onShowMoreRooms} /> onShowMoreRooms={self.onShowMoreRooms} />
</div> </div>
</GeminiScrollbar> </GeminiScrollbar>
</DragDropContext>
); );
}, },
}); });

256
src/stores/RoomListStore.js Normal file
View file

@ -0,0 +1,256 @@
/*
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';
import Unread from '../Unread';
/**
* A class for storing application state for categorising rooms in
* the RoomList.
*/
class RoomListStore extends Store {
constructor() {
super(dis);
this._init();
this._getManualComparator = this._getManualComparator.bind(this);
this._recentsComparator = this._recentsComparator.bind(this);
}
_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);
this.__emitChange();
}
__onDispatch(payload) {
switch (payload.action) {
// Initialise state after initial sync
case 'MatrixActions.sync': {
if (!(payload.prevState !== 'PREPARED' && payload.state === 'PREPARED')) {
break;
}
this._matrixClient = payload.matrixClient;
this._generateRoomLists();
}
break;
case 'MatrixActions.Room.tags': {
if (!this._state.ready) break;
this._generateRoomLists();
}
break;
case 'MatrixActions.accountData': {
if (payload.event_type !== 'm.direct') break;
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': {
// 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;
case 'RoomListActions.tagRoom.failure': {
// 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._matrixClient = null;
}
break;
}
}
_generateRoomLists(optimisticRequest) {
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();
// 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") {
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
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];
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");
}
});
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;
}
}
if (global.singletonRoomListStore === undefined) {
global.singletonRoomListStore = new RoomListStore();
}
export default global.singletonRoomListStore;