From 45bcb6f2ed5bff69b0272c86c14ceea2268bd8eb Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 29 Nov 2017 16:35:16 +0000 Subject: [PATCH 1/3] 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. --- src/components/structures/LoggedInView.js | 2 + src/components/structures/TagPanel.js | 173 ++++++++++++++++++++++ src/components/views/rooms/RoomList.js | 76 +++++++++- src/stores/FilterStore.js | 65 ++++++++ src/stores/GroupStoreCache.js | 1 - 5 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 src/components/structures/TagPanel.js create mode 100644 src/stores/FilterStore.js diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 9a293bfc8a..6962d2c08d 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -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({
{ topBar }
+ : +
; + return +
+ + { tip } +
+
; + }, +}); + +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 ; + }); + return
+
+ { tags } +
+ + + +
; + }, +}); diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index ebe0bdb03f..6157f65c9d 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -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) { diff --git a/src/stores/FilterStore.js b/src/stores/FilterStore.js new file mode 100644 index 0000000000..6e2a7f4739 --- /dev/null +++ b/src/stores/FilterStore.js @@ -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; diff --git a/src/stores/GroupStoreCache.js b/src/stores/GroupStoreCache.js index 3264b197d7..8b4286831b 100644 --- a/src/stores/GroupStoreCache.js +++ b/src/stores/GroupStoreCache.js @@ -28,7 +28,6 @@ class GroupStoreCache { // referencing it. this.groupStore = new GroupStore(groupId); } - this.groupStore._fetchSummary(); return this.groupStore; } } From ead30fae9d7d318aef30f1e89ec85457a64d6402 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 29 Nov 2017 17:07:43 +0000 Subject: [PATCH 2/3] Use unmounted instead of mounted --- src/components/structures/TagPanel.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 6900b7e47b..242b71cd16 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -105,11 +105,11 @@ export default React.createClass({ }, componentWillMount: function() { - this.mounted = true; + this.unmounted = false; this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership); this._filterStoreToken = FilterStore.addListener(() => { - if (!this.mounted) { + if (this.unmounted) { return; } this.setState({ @@ -121,7 +121,7 @@ export default React.createClass({ }, componentWillUnmount() { - this.mounted = false; + this.unmounted = true; this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership); if (this._filterStoreToken) { this._filterStoreToken.remove(); @@ -129,7 +129,7 @@ export default React.createClass({ }, _onGroupMyMembership() { - if (!this.mounted) return; + if (this.unmounted) return; this._fetchJoinedRooms(); }, From f708250d446d5189da24e118cf4245d522d2e885 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 29 Nov 2017 18:00:42 +0000 Subject: [PATCH 3/3] Add feature flag for Tag Panel --- src/components/structures/LoggedInView.js | 2 +- src/settings/Settings.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 6962d2c08d..2f0ee5e47d 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -335,7 +335,7 @@ export default React.createClass({
{ topBar }
- + { SettingsStore.isFeatureEnabled("feature_tag_panel") ? :
}