Implement TagPanel (or LeftLeftPanel) for group filtering

This allows for filtering of the RoomList by group. When a group is selected, the room list will show:
 - Rooms in the group
 - Direct messages with members in the group

A button at the bottom of the TagPanel allows for creating new groups, which will appear in the panel following creation.
This commit is contained in:
Luke Barnard 2017-11-29 16:35:16 +00:00
parent ff25c2f329
commit 45bcb6f2ed
5 changed files with 311 additions and 6 deletions

View file

@ -213,6 +213,7 @@ export default 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');
@ -334,6 +335,7 @@ export default React.createClass({
<div className='mx_MatrixChat_wrapper'>
{ topBar }
<div className={bodyClasses}>
<TagPanel />
<LeftPanel
selectedRoom={this.props.currentRoomId}
collapsed={this.props.collapseLhs || false}

View file

@ -0,0 +1,173 @@
/*
Copyright 2017 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 React from 'react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import classNames from 'classnames';
import FilterStore from '../../stores/FilterStore';
import FlairStore from '../../stores/FlairStore';
import sdk from '../../index';
import dis from '../../dispatcher';
const TagTile = React.createClass({
displayName: 'TagTile',
propTypes: {
groupProfile: PropTypes.object,
},
contextTypes: {
matrixClient: React.PropTypes.instanceOf(MatrixClient).isRequired,
},
getInitialState() {
return {
hover: false,
};
},
onClick: function(e) {
e.preventDefault();
e.stopPropagation();
dis.dispatch({
action: 'view_group',
group_id: this.props.groupProfile.groupId,
});
dis.dispatch({
action: 'select_tag',
tag: this.props.groupProfile.groupId,
});
},
onMouseOver: function() {
this.setState({hover: true});
},
onMouseOut: function() {
this.setState({hover: false});
},
render: function() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const RoomTooltip = sdk.getComponent('rooms.RoomTooltip');
const profile = this.props.groupProfile || {};
const name = profile.name || profile.groupId;
const avatarHeight = 35;
const httpUrl = profile.avatarUrl ? this.context.matrixClient.mxcUrlToHttp(
profile.avatarUrl, avatarHeight, avatarHeight, "crop",
) : null;
const className = classNames({
mx_TagTile: true,
mx_TagTile_selected: this.props.selected,
});
const tip = this.state.hover ?
<RoomTooltip className="mx_TagTile_tooltip" label={name} /> :
<div />;
return <AccessibleButton className={className} onClick={this.onClick}>
<div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}>
<BaseAvatar name={name} url={httpUrl} width={avatarHeight} height={avatarHeight} />
{ tip }
</div>
</AccessibleButton>;
},
});
export default React.createClass({
displayName: 'TagPanel',
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
getInitialState() {
return {
joinedGroupProfiles: [],
selectedTags: [],
};
},
componentWillMount: function() {
this.mounted = true;
this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership);
this._filterStoreToken = FilterStore.addListener(() => {
if (!this.mounted) {
return;
}
this.setState({
selectedTags: FilterStore.getSelectedTags(),
});
});
this._fetchJoinedRooms();
},
componentWillUnmount() {
this.mounted = false;
this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
if (this._filterStoreToken) {
this._filterStoreToken.remove();
}
},
_onGroupMyMembership() {
if (!this.mounted) return;
this._fetchJoinedRooms();
},
onClick() {
dis.dispatch({action: 'deselect_tags'});
},
onCreateGroupClick(ev) {
ev.stopPropagation();
dis.dispatch({action: 'view_create_group'});
},
async _fetchJoinedRooms() {
const joinedGroupResponse = await this.context.matrixClient.getJoinedGroups();
const joinedGroupIds = joinedGroupResponse.groups;
const joinedGroupProfiles = await Promise.all(joinedGroupIds.map(
(groupId) => FlairStore.getGroupProfileCached(this.context.matrixClient, groupId),
));
this.setState({joinedGroupProfiles});
},
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const tags = this.state.joinedGroupProfiles.map((groupProfile, index) => {
return <TagTile
key={groupProfile.groupId + '_' + index}
groupProfile={groupProfile}
selected={this.state.selectedTags.includes(groupProfile.groupId)}
/>;
});
return <div className="mx_TagPanel" onClick={this.onClick}>
<div className="mx_TagPanel_tagTileContainer">
{ tags }
</div>
<AccessibleButton className="mx_TagPanel_createGroupButton" onClick={this.onCreateGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="25" height="25" />
</AccessibleButton>
</div>;
},
});

View file

@ -28,6 +28,8 @@ const rate_limited_func = require('../../../ratelimitedfunc');
const Rooms = require('../../../Rooms');
import DMRoomMap from '../../../utils/DMRoomMap';
const Receipt = require('../../../utils/Receipt');
import FilterStore from '../../../stores/FilterStore';
import GroupStoreCache from '../../../stores/GroupStoreCache';
const HIDE_CONFERENCE_CHANS = true;
@ -61,6 +63,7 @@ module.exports = React.createClass({
totalRoomCount: null,
lists: {},
incomingCall: null,
selectedTags: [],
};
},
@ -80,6 +83,23 @@ module.exports = React.createClass({
cli.on("accountData", this.onAccountData);
cli.on("Group.myMembership", this._onGroupMyMembership);
this._groupStores = {};
this._selectedTagsRoomIds = [];
this._selectedTagsUserIds = [];
// When the selected tags are changed, initialise a group store if necessary
this._filterStoreToken = FilterStore.addListener(() => {
FilterStore.getSelectedTags().forEach((tag) => {
if (tag[0] !== '+' || this._groupStores[tag]) {
return;
}
this._groupStores[tag] = GroupStoreCache.getGroupStore(tag);
this._groupStores[tag].registerListener(() => {
this.updateSelectedTagsEntities();
});
});
this.updateSelectedTagsEntities();
});
this.refreshRoomList();
// order of the sublists
@ -148,6 +168,11 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
MatrixClientPeg.get().removeListener("Group.myMembership", this._onGroupMyMembership);
}
if (this._filterStoreToken) {
this._filterStoreToken.remove();
}
// cancel any pending calls to the rate_limited_funcs
this._delayedRefreshRoomList.cancelPendingCall();
},
@ -234,6 +259,41 @@ module.exports = React.createClass({
this.refreshRoomList();
}, 500),
// Update which rooms and users should appear in RoomList as dictated by selected tags
updateSelectedTagsEntities: function() {
if (!this.mounted) return;
this._selectedTagsRoomIds = [];
this._selectedTagsUserIds = [];
FilterStore.getSelectedTags().forEach((tag) => {
this._selectedTagsRoomIds = this._selectedTagsRoomIds.concat(
this._groupStores[tag].getGroupRooms().map((room) => room.roomId),
);
// TODO: Check if room has been tagged to the group by the user
this._selectedTagsUserIds = this._selectedTagsUserIds.concat(
this._groupStores[tag].getGroupMembers().map((member) => member.userId),
);
});
this.setState({
selectedTags: FilterStore.getSelectedTags(),
}, () => {
this.refreshRoomList();
});
},
isRoomInSelectedTags: function(room, me, dmRoomMap) {
// No selected tags = every room is visible in the list
if (this.state.selectedTags.length === 0) {
return true;
}
if (this._selectedTagsRoomIds.includes(room.roomId)) {
return true;
}
const dmUserId = dmRoomMap.getUserIdForRoomId(room.roomId);
return dmUserId && dmUserId !== me.userId &&
this._selectedTagsUserIds.includes(dmUserId);
},
refreshRoomList: function() {
// TODO: ideally we'd calculate this once at start, and then maintain
// any changes to it incrementally, updating the appropriate sublists
@ -253,9 +313,7 @@ module.exports = React.createClass({
},
getRoomLists: function() {
const self = this;
const lists = {};
lists["im.vector.fake.invite"] = [];
lists["m.favourite"] = [];
lists["im.vector.fake.recent"] = [];
@ -264,8 +322,7 @@ module.exports = React.createClass({
lists["im.vector.fake.archived"] = [];
const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
MatrixClientPeg.get().getRooms().forEach(function(room) {
MatrixClientPeg.get().getRooms().forEach((room) => {
const me = room.getMember(MatrixClientPeg.get().credentials.userId);
if (!me) return;
@ -276,13 +333,18 @@ module.exports = React.createClass({
if (me.membership == "invite") {
lists["im.vector.fake.invite"].push(room);
} else if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, me, self.props.ConferenceHandler)) {
} 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);
// Apply TagPanel filtering, derived from FilterStore
if (!this.isRoomInSelectedTags(room, me, dmRoomMap)) {
return;
}
if (tagNames.length) {
for (let i = 0; i < tagNames.length; i++) {
const tagName = tagNames[i];
@ -474,6 +536,10 @@ module.exports = React.createClass({
},
_getEmptyContent: function(section) {
if (this.state.selectedTags.length > 0) {
return null;
}
const RoomDropTarget = sdk.getComponent('rooms.RoomDropTarget');
if (this.props.collapsed) {

65
src/stores/FilterStore.js Normal file
View file

@ -0,0 +1,65 @@
/*
Copyright 2017 Vector Creations 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 Analytics from '../Analytics';
const INITIAL_STATE = {
tags: [],
};
/**
* A class for storing application state for filtering via TagPanel.
*/
class FilterStore extends Store {
constructor() {
super(dis);
// Initialise state
this._state = INITIAL_STATE;
}
_setState(newState) {
this._state = Object.assign(this._state, newState);
this.__emitChange();
}
__onDispatch(payload) {
switch (payload.action) {
case 'select_tag':
this._setState({
tags: [payload.tag],
});
Analytics.trackEvent('FilterStore', 'select_tag');
break;
case 'deselect_tags':
this._setState({
tags: [],
});
Analytics.trackEvent('FilterStore', 'deselect_tags');
break;
}
}
getSelectedTags() {
return this._state.tags;
}
}
if (global.singletonFilterStore === undefined) {
global.singletonFilterStore = new FilterStore();
}
export default global.singletonFilterStore;

View file

@ -28,7 +28,6 @@ class GroupStoreCache {
// referencing it.
this.groupStore = new GroupStore(groupId);
}
this.groupStore._fetchSummary();
return this.groupStore;
}
}