diff --git a/package.json b/package.json index 5c81db2153a..943c443c596 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,8 @@ "querystring": "^0.2.0", "react": "^15.4.0", "react-addons-css-transition-group": "15.3.2", + "react-dnd": "^2.1.4", + "react-dnd-html5-backend": "^2.1.2", "react-dom": "^15.4.0", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "sanitize-html": "^1.14.1", diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index a6012f5213a..14dfa91fa48 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd. +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. @@ -22,6 +23,7 @@ import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline'; import EventTimelineSet from 'matrix-js-sdk/lib/models/event-timeline-set'; import createMatrixClient from './utils/createMatrixClient'; import SettingsStore from './settings/SettingsStore'; +import MatrixActionCreators from './actions/MatrixActionCreators'; interface MatrixClientCreds { homeserverUrl: string, @@ -68,6 +70,8 @@ class MatrixClientPeg { unset() { this.matrixClient = null; + + MatrixActionCreators.stop(); } /** @@ -108,6 +112,9 @@ class MatrixClientPeg { // regardless of errors, start the client. If we did error out, we'll // just end up doing a full initial /sync. + // Connect the matrix client to the dispatcher + MatrixActionCreators.start(this.matrixClient); + console.log(`MatrixClientPeg: really starting MatrixClient`); this.get().startClient(opts); console.log(`MatrixClientPeg: MatrixClient started`); diff --git a/src/actions/GroupActions.js b/src/actions/GroupActions.js new file mode 100644 index 00000000000..006c2da5b8b --- /dev/null +++ b/src/actions/GroupActions.js @@ -0,0 +1,34 @@ +/* +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 { asyncAction } from './actionCreators'; + +const GroupActions = {}; + +/** + * Creates an action thunk that will do an asynchronous request to fetch + * the groups to which a user is joined. + * + * @param {MatrixClient} matrixClient the matrix client to query. + * @returns {function} an action thunk that will dispatch actions + * indicating the status of the request. + * @see asyncAction + */ +GroupActions.fetchJoinedGroups = function(matrixClient) { + return asyncAction('GroupActions.fetchJoinedGroups', () => matrixClient.getJoinedGroups()); +}; + +export default GroupActions; diff --git a/src/actions/MatrixActionCreators.js b/src/actions/MatrixActionCreators.js new file mode 100644 index 00000000000..33bdb53799f --- /dev/null +++ b/src/actions/MatrixActionCreators.js @@ -0,0 +1,108 @@ +/* +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 dis from '../dispatcher'; + +// TODO: migrate from sync_state to MatrixActions.sync so that more js-sdk events +// become dispatches in the same place. +/** + * Create a MatrixActions.sync action that represents a MatrixClient `sync` event, + * each parameter mapping to a key-value in the action. + * + * @param {MatrixClient} matrixClient the matrix client + * @param {string} state the current sync state. + * @param {string} prevState the previous sync state. + * @returns {Object} an action of type MatrixActions.sync. + */ +function createSyncAction(matrixClient, state, prevState) { + return { + action: 'MatrixActions.sync', + state, + prevState, + matrixClient, + }; +} + +/** + * @typedef AccountDataAction + * @type {Object} + * @property {string} action 'MatrixActions.accountData'. + * @property {MatrixEvent} event the MatrixEvent that triggered the dispatch. + * @property {string} event_type the type of the MatrixEvent, e.g. "m.direct". + * @property {Object} event_content the content of the MatrixEvent. + */ + +/** + * Create a MatrixActions.accountData action that represents a MatrixClient `accountData` + * matrix event. + * + * @param {MatrixClient} matrixClient the matrix client. + * @param {MatrixEvent} accountDataEvent the account data event. + * @returns {AccountDataAction} an action of type MatrixActions.accountData. + */ +function createAccountDataAction(matrixClient, accountDataEvent) { + return { + action: 'MatrixActions.accountData', + event: accountDataEvent, + event_type: accountDataEvent.getType(), + event_content: accountDataEvent.getContent(), + }; +} + +/** + * This object is responsible for dispatching actions when certain events are emitted by + * the given MatrixClient. + */ +export default { + // A list of callbacks to call to unregister all listeners added + _matrixClientListenersStop: [], + + /** + * Start listening to certain events from the MatrixClient and dispatch actions when + * they are emitted. + * @param {MatrixClient} matrixClient the MatrixClient to listen to events from + */ + start(matrixClient) { + this._addMatrixClientListener(matrixClient, 'sync', createSyncAction); + this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); + }, + + /** + * Start listening to events of type eventName on matrixClient and when they are emitted, + * dispatch an action created by the actionCreator function. + * @param {MatrixClient} matrixClient a MatrixClient to register a listener with. + * @param {string} eventName the event to listen to on MatrixClient. + * @param {function} actionCreator a function that should return an action to dispatch + * when given the MatrixClient as an argument as well as + * arguments emitted in the MatrixClient event. + */ + _addMatrixClientListener(matrixClient, eventName, actionCreator) { + const listener = (...args) => { + dis.dispatch(actionCreator(matrixClient, ...args)); + }; + matrixClient.on(eventName, listener); + this._matrixClientListenersStop.push(() => { + matrixClient.removeListener(eventName, listener); + }); + }, + + /** + * Stop listening to events. + */ + stop() { + this._matrixClientListenersStop.forEach((stopListener) => stopListener()); + }, +}; diff --git a/src/actions/TagOrderActions.js b/src/actions/TagOrderActions.js new file mode 100644 index 00000000000..60946ea7f1b --- /dev/null +++ b/src/actions/TagOrderActions.js @@ -0,0 +1,47 @@ +/* +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 Analytics from '../Analytics'; +import { asyncAction } from './actionCreators'; +import TagOrderStore from '../stores/TagOrderStore'; + +const TagOrderActions = {}; + +/** + * Creates an action thunk that will do an asynchronous request to + * commit TagOrderStore.getOrderedTags() to account data and dispatch + * actions to indicate the status of the request. + * + * @param {MatrixClient} matrixClient the matrix client to set the + * account data on. + * @returns {function} an action thunk that will dispatch actions + * indicating the status of the request. + * @see asyncAction + */ +TagOrderActions.commitTagOrdering = function(matrixClient) { + return asyncAction('TagOrderActions.commitTagOrdering', () => { + // Only commit tags if the state is ready, i.e. not null + const tags = TagOrderStore.getOrderedTags(); + if (!tags) { + return; + } + + Analytics.trackEvent('TagOrderActions', 'commitTagOrdering'); + return matrixClient.setAccountData('im.vector.web.tag_ordering', {tags}); + }); +}; + +export default TagOrderActions; diff --git a/src/actions/actionCreators.js b/src/actions/actionCreators.js new file mode 100644 index 00000000000..bddfbc7c63c --- /dev/null +++ b/src/actions/actionCreators.js @@ -0,0 +1,41 @@ +/* +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. +*/ + +/** + * Create an action thunk that will dispatch actions indicating the current + * status of the Promise returned by fn. + * + * @param {string} id the id to give the dispatched actions. This is given a + * suffix determining whether it is pending, successful or + * a failure. + * @param {function} fn a function that returns a Promise. + * @returns {function} an action thunk - a function that uses its single + * argument as a dispatch function to dispatch the + * following actions: + * `${id}.pending` and either + * `${id}.success` or + * `${id}.failure`. + */ +export function asyncAction(id, fn) { + return (dispatch) => { + dispatch({action: id + '.pending'}); + fn().then((result) => { + dispatch({action: id + '.success', result}); + }).catch((err) => { + dispatch({action: id + '.failure', err}); + }); + }; +} diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 01abf966f9f..38b7634edb9 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -18,6 +18,8 @@ limitations under the License. import * as Matrix from 'matrix-js-sdk'; import React from 'react'; +import { DragDropContext } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; import Notifier from '../../Notifier'; @@ -38,7 +40,7 @@ import SettingsStore from "../../settings/SettingsStore"; * * Components mounted below us can access the matrix client via the react context. */ -export default React.createClass({ +const LoggedInView = React.createClass({ displayName: 'LoggedInView', propTypes: { @@ -344,3 +346,5 @@ export default React.createClass({ ); }, }); + +export default DragDropContext(HTML5Backend)(LoggedInView); diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index ba7251b603a..69b737cb7ef 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -83,7 +83,7 @@ const ONBOARDING_FLOW_STARTERS = [ 'view_create_group', ]; -module.exports = React.createClass({ +export default React.createClass({ // we export this so that the integration tests can use it :-S statics: { VIEWS: VIEWS, diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 0107ad1db15..49d22d8e529 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -17,79 +17,17 @@ 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'; -import { isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; - -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: 'select_tag', - tag: this.props.groupProfile.groupId, - ctrlOrCmdKey: isOnlyCtrlOrCmdKeyEvent(e), - shiftKey: e.shiftKey, - }); - }, - - onMouseOver: function() { - this.setState({hover: true}); - }, - - onMouseOut: function() { - this.setState({hover: false}); - }, +import TagOrderStore from '../../stores/TagOrderStore'; - 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, - }); +import GroupActions from '../../actions/GroupActions'; +import TagOrderActions from '../../actions/TagOrderActions'; - const tip = this.state.hover ? - : -
; - return -
- - { tip } -
-
; - }, -}); +import sdk from '../../index'; +import dis from '../../dispatcher'; -export default React.createClass({ +const TagPanel = React.createClass({ displayName: 'TagPanel', contextTypes: { @@ -98,7 +36,17 @@ export default React.createClass({ getInitialState() { return { - joinedGroupProfiles: [], + // A list of group profiles for tags that are group IDs. The intention in future + // is to allow arbitrary tags to be selected in the TagPanel, not just groups. + // For now, it suffices to maintain a list of ordered group profiles. + orderedGroupTagProfiles: [ + // { + // groupId: '+awesome:foo.bar',{ + // name: 'My Awesome Community', + // avatarUrl: 'mxc://...', + // shortDescription: 'Some description...', + // }, + ], selectedTags: [], }; }, @@ -115,8 +63,23 @@ export default React.createClass({ selectedTags: FilterStore.getSelectedTags(), }); }); + this._tagOrderStoreToken = TagOrderStore.addListener(() => { + if (this.unmounted) { + return; + } - this._fetchJoinedRooms(); + const orderedTags = TagOrderStore.getOrderedTags() || []; + const orderedGroupTags = orderedTags.filter((t) => t[0] === '+'); + // XXX: One profile lookup failing will bring the whole lot down + Promise.all(orderedGroupTags.map( + (groupId) => FlairStore.getGroupProfileCached(this.context.matrixClient, groupId), + )).then((orderedGroupTagProfiles) => { + if (this.unmounted) return; + this.setState({orderedGroupTagProfiles}); + }); + }); + // This could be done by anything with a matrix client + dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); }, componentWillUnmount() { @@ -129,7 +92,7 @@ export default React.createClass({ _onGroupMyMembership() { if (this.unmounted) return; - this._fetchJoinedRooms(); + dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); }, onClick() { @@ -141,27 +104,21 @@ export default React.createClass({ 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), - )); - dis.dispatch({ - action: 'all_tags', - tags: joinedGroupIds, - }); - this.setState({joinedGroupProfiles}); + onTagTileEndDrag() { + dis.dispatch(TagOrderActions.commitTagOrdering(this.context.matrixClient)); }, render() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const TintableSvg = sdk.getComponent('elements.TintableSvg'); - const tags = this.state.joinedGroupProfiles.map((groupProfile, index) => { - return { + return ; }); return
@@ -174,3 +131,4 @@ export default React.createClass({
; }, }); +export default TagPanel; diff --git a/src/components/views/elements/DNDTagTile.js b/src/components/views/elements/DNDTagTile.js new file mode 100644 index 00000000000..4d035349805 --- /dev/null +++ b/src/components/views/elements/DNDTagTile.js @@ -0,0 +1,85 @@ +/* eslint new-cap: "off" */ +/* +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 { DragSource, DropTarget } from 'react-dnd'; + +import TagTile from './TagTile'; +import dis from '../../../dispatcher'; +import { findDOMNode } from 'react-dom'; + +const tagTileSource = { + canDrag: function(props, monitor) { + return true; + }, + + beginDrag: function(props) { + // Return the data describing the dragged item + return { + tag: props.groupProfile.groupId, + }; + }, + + endDrag: function(props, monitor, component) { + const dropResult = monitor.getDropResult(); + if (!monitor.didDrop() || !dropResult) { + return; + } + props.onEndDrag(); + }, +}; + +const tagTileTarget = { + canDrop(props, monitor) { + return true; + }, + + hover(props, monitor, component) { + if (!monitor.canDrop()) return; + const draggedY = monitor.getClientOffset().y; + const {top, bottom} = findDOMNode(component).getBoundingClientRect(); + const targetY = (top + bottom) / 2; + dis.dispatch({ + action: 'order_tag', + tag: monitor.getItem().tag, + targetTag: props.groupProfile.groupId, + // Note: we indicate that the tag should be after the target when + // it's being dragged over the top half of the target. + after: draggedY < targetY, + }); + }, + + drop(props) { + // Return the data to be returned by getDropResult + return { + tag: props.groupProfile.groupId, + }; + }, +}; + +export default + DropTarget('TagTile', tagTileTarget, (connect, monitor) => ({ + connectDropTarget: connect.dropTarget(), + }))(DragSource('TagTile', tagTileSource, (connect, monitor) => ({ + connectDragSource: connect.dragSource(), + }))((props) => { + const { connectDropTarget, connectDragSource, ...otherProps } = props; + return connectDropTarget(connectDragSource( +
+ +
, + )); + })); diff --git a/src/components/views/elements/TagTile.js b/src/components/views/elements/TagTile.js new file mode 100644 index 00000000000..124559a838c --- /dev/null +++ b/src/components/views/elements/TagTile.js @@ -0,0 +1,88 @@ +/* +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 classNames from 'classnames'; +import { MatrixClient } from 'matrix-js-sdk'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import { isOnlyCtrlOrCmdKeyEvent } from '../../../Keyboard'; + +export default 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: 'select_tag', + tag: this.props.groupProfile.groupId, + ctrlOrCmdKey: isOnlyCtrlOrCmdKeyEvent(e), + shiftKey: e.shiftKey, + }); + }, + + 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 ? + : +
; + return +
+ + { tip } +
+
; + }, +}); diff --git a/src/dispatcher.js b/src/dispatcher.js index be74dc856eb..48c8dc86e94 100644 --- a/src/dispatcher.js +++ b/src/dispatcher.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +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. @@ -20,14 +21,24 @@ const flux = require("flux"); class MatrixDispatcher extends flux.Dispatcher { /** - * @param {Object} payload Required. The payload to dispatch. - * Must contain at least an 'action' key. + * @param {Object|function} payload Required. The payload to dispatch. + * If an Object, must contain at least an 'action' key. + * If a function, must have the signature (dispatch) => {...}. * @param {boolean=} sync Optional. Pass true to dispatch * synchronously. This is useful for anything triggering * an operation that the browser requires user interaction * for. */ dispatch(payload, sync) { + // Allow for asynchronous dispatching by accepting payloads that have the + // type `function (dispatch) {...}` + if (typeof payload === 'function') { + payload((action) => { + this.dispatch(action, sync); + }); + return; + } + if (sync) { super.dispatch(payload); } else { diff --git a/src/stores/TagOrderStore.js b/src/stores/TagOrderStore.js new file mode 100644 index 00000000000..633ffc7e9c5 --- /dev/null +++ b/src/stores/TagOrderStore.js @@ -0,0 +1,137 @@ +/* +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 {Store} from 'flux/utils'; +import dis from '../dispatcher'; + +const INITIAL_STATE = { + orderedTags: null, + orderedTagsAccountData: null, + hasSynced: false, + joinedGroupIds: null, +}; + +/** + * A class for storing application state for ordering tags in the TagPanel. + */ +class TagOrderStore extends Store { + constructor() { + super(dis); + + // Initialise state + this._state = Object.assign({}, INITIAL_STATE); + } + + _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 === 'SYNCING')) { + break; + } + const tagOrderingEvent = payload.matrixClient.getAccountData('im.vector.web.tag_ordering'); + const tagOrderingEventContent = tagOrderingEvent ? tagOrderingEvent.getContent() : {}; + this._setState({ + orderedTagsAccountData: tagOrderingEventContent.tags || null, + hasSynced: true, + }); + this._updateOrderedTags(); + break; + } + // Get ordering from account data + case 'MatrixActions.accountData': { + if (payload.event_type !== 'im.vector.web.tag_ordering') break; + this._setState({ + orderedTagsAccountData: payload.event_content ? payload.event_content.tags : null, + }); + this._updateOrderedTags(); + break; + } + // Initialise the state such that if account data is unset, default to joined groups + case 'GroupActions.fetchJoinedGroups.success': { + this._setState({ + joinedGroupIds: payload.result.groups.sort(), // Sort lexically + hasFetchedJoinedGroups: true, + }); + this._updateOrderedTags(); + break; + } + // Puts payload.tag at payload.targetTag, placing the targetTag before or after the tag + case 'order_tag': { + if (!this._state.orderedTags || + !payload.tag || + !payload.targetTag || + payload.tag === payload.targetTag + ) return; + + const tags = this._state.orderedTags; + + let orderedTags = tags.filter((t) => t !== payload.tag); + const newIndex = orderedTags.indexOf(payload.targetTag) + (payload.after ? 1 : 0); + orderedTags = [ + ...orderedTags.slice(0, newIndex), + payload.tag, + ...orderedTags.slice(newIndex), + ]; + this._setState({orderedTags}); + 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._state = Object.assign({}, INITIAL_STATE); + break; + } + } + } + + _updateOrderedTags() { + this._setState({ + orderedTags: + this._state.hasSynced && + this._state.hasFetchedJoinedGroups ? + this._mergeGroupsAndTags() : null, + }); + } + + _mergeGroupsAndTags() { + const groupIds = this._state.joinedGroupIds || []; + const tags = this._state.orderedTagsAccountData || []; + + const tagsToKeep = tags.filter( + (t) => t[0] !== '+' || groupIds.includes(t), + ); + + const groupIdsToAdd = groupIds.filter( + (groupId) => !tags.includes(groupId), + ); + + return tagsToKeep.concat(groupIdsToAdd); + } + + getOrderedTags() { + return this._state.orderedTags; + } +} + +if (global.singletonTagOrderStore === undefined) { + global.singletonTagOrderStore = new TagOrderStore(); +} +export default global.singletonTagOrderStore;