diff --git a/.travis.yml b/.travis.yml
index 4137d754bf..954f14a4da 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,7 +3,10 @@ dist: trusty
# we don't need sudo, so can run in a container, which makes startup much
# quicker.
-sudo: false
+#
+# unfortunately we do temporarily require sudo as a workaround for
+# https://github.com/travis-ci/travis-ci/issues/8836
+sudo: required
language: node_js
node_js:
diff --git a/package.json b/package.json
index eb2cabf854..dbc27b5a08 100644
--- a/package.json
+++ b/package.json
@@ -77,6 +77,7 @@
"querystring": "^0.2.0",
"react": "^15.4.0",
"react-addons-css-transition-group": "15.3.2",
+ "react-beautiful-dnd": "^4.0.0",
"react-dnd": "^2.1.4",
"react-dnd-html5-backend": "^2.1.2",
"react-dom": "^15.4.0",
diff --git a/src/actions/TagOrderActions.js b/src/actions/TagOrderActions.js
index 60946ea7f1..608fd6c4c5 100644
--- a/src/actions/TagOrderActions.js
+++ b/src/actions/TagOrderActions.js
@@ -22,25 +22,32 @@ 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.
+ * move a tag in TagOrderStore to destinationIx.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
+ * @param {string} tag the tag to move.
+ * @param {number} destinationIx the new position of the tag.
* @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;
- }
+TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) {
+ // Only commit tags if the state is ready, i.e. not null
+ let tags = TagOrderStore.getOrderedTags();
+ if (!tags) {
+ return;
+ }
+ tags = tags.filter((t) => t !== tag);
+ tags = [...tags.slice(0, destinationIx), tag, ...tags.slice(destinationIx)];
+
+ return asyncAction('TagOrderActions.moveTag', () => {
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
return matrixClient.setAccountData('im.vector.web.tag_ordering', {tags});
+ }, () => {
+ // For an optimistic update
+ return {tags};
});
};
diff --git a/src/actions/actionCreators.js b/src/actions/actionCreators.js
index bddfbc7c63..0238eee8c0 100644
--- a/src/actions/actionCreators.js
+++ b/src/actions/actionCreators.js
@@ -22,6 +22,9 @@ limitations under the License.
* suffix determining whether it is pending, successful or
* a failure.
* @param {function} fn a function that returns a Promise.
+ * @param {function?} pendingFn a function that returns an object to assign
+ * to the `request` key of the ${id}.pending
+ * payload.
* @returns {function} an action thunk - a function that uses its single
* argument as a dispatch function to dispatch the
* following actions:
@@ -29,9 +32,13 @@ limitations under the License.
* `${id}.success` or
* `${id}.failure`.
*/
-export function asyncAction(id, fn) {
+export function asyncAction(id, fn, pendingFn) {
return (dispatch) => {
- dispatch({action: id + '.pending'});
+ dispatch({
+ action: id + '.pending',
+ request:
+ typeof pendingFn === 'function' ? pendingFn() : undefined,
+ });
fn().then((result) => {
dispatch({action: id + '.success', result});
}).catch((err) => {
diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js
index 531c247ea6..1cd3f04f9d 100644
--- a/src/components/structures/TagPanel.js
+++ b/src/components/structures/TagPanel.js
@@ -25,6 +25,8 @@ import TagOrderActions from '../../actions/TagOrderActions';
import sdk from '../../index';
import dis from '../../dispatcher';
+import { DragDropContext, Droppable } from 'react-beautiful-dnd';
+
const TagPanel = React.createClass({
displayName: 'TagPanel',
@@ -69,7 +71,9 @@ const TagPanel = React.createClass({
dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient));
},
- onClick() {
+ onClick(e) {
+ // Ignore clicks on children
+ if (e.target !== e.currentTarget) return;
dis.dispatch({action: 'deselect_tags'});
},
@@ -78,8 +82,20 @@ const TagPanel = React.createClass({
dis.dispatch({action: 'view_create_group'});
},
- onTagTileEndDrag() {
- dis.dispatch(TagOrderActions.commitTagOrdering(this.context.matrixClient));
+ 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() {
@@ -89,16 +105,31 @@ const TagPanel = React.createClass({
const tags = this.state.orderedTags.map((tag, index) => {
return