diff --git a/src/DateUtils.js b/src/DateUtils.js
index 986525eec8..108697238c 100644
--- a/src/DateUtils.js
+++ b/src/DateUtils.js
@@ -50,11 +50,15 @@ function pad(n) {
return (n < 10 ? '0' : '') + n;
}
-function twelveHourTime(date) {
+function twelveHourTime(date, showSeconds=false) {
let hours = date.getHours() % 12;
const minutes = pad(date.getMinutes());
const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM');
hours = hours ? hours : 12; // convert 0 -> 12
+ if (showSeconds) {
+ const seconds = pad(date.getSeconds());
+ return `${hours}:${minutes}:${seconds}${ampm}`;
+ }
return `${hours}:${minutes}${ampm}`;
}
@@ -101,10 +105,17 @@ export function formatFullDate(date, showTwelveHour=false) {
monthName: months[date.getMonth()],
day: date.getDate(),
fullYear: date.getFullYear(),
- time: formatTime(date, showTwelveHour),
+ time: formatFullTime(date, showTwelveHour),
});
}
+export function formatFullTime(date, showTwelveHour=false) {
+ if (showTwelveHour) {
+ return twelveHourTime(date, true);
+ }
+ return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
+}
+
export function formatTime(date, showTwelveHour=false) {
if (showTwelveHour) {
return twelveHourTime(date);
diff --git a/src/actions/MatrixActionCreators.js b/src/actions/MatrixActionCreators.js
index 33bdb53799..dbfe910533 100644
--- a/src/actions/MatrixActionCreators.js
+++ b/src/actions/MatrixActionCreators.js
@@ -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
* the given MatrixClient.
@@ -78,6 +86,8 @@ export default {
start(matrixClient) {
this._addMatrixClientListener(matrixClient, 'sync', createSyncAction);
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) {
const listener = (...args) => {
- dis.dispatch(actionCreator(matrixClient, ...args));
+ dis.dispatch(actionCreator(matrixClient, ...args), true);
};
matrixClient.on(eventName, listener);
this._matrixClientListenersStop.push(() => {
diff --git a/src/actions/RoomListActions.js b/src/actions/RoomListActions.js
new file mode 100644
index 0000000000..a92bd1ebaf
--- /dev/null
+++ b/src/actions/RoomListActions.js
@@ -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;
diff --git a/src/actions/TagOrderActions.js b/src/actions/TagOrderActions.js
index dd4df6a9d4..38bada2228 100644
--- a/src/actions/TagOrderActions.js
+++ b/src/actions/TagOrderActions.js
@@ -35,6 +35,7 @@ const TagOrderActions = {};
TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) {
// Only commit tags if the state is ready, i.e. not null
let tags = TagOrderStore.getOrderedTags();
+ let removedTags = TagOrderStore.getRemovedTagsAccountData();
if (!tags) {
return;
}
@@ -42,17 +43,66 @@ TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) {
tags = tags.filter((t) => t !== tag);
tags = [...tags.slice(0, destinationIx), tag, ...tags.slice(destinationIx)];
+ removedTags = removedTags.filter((t) => t !== tag);
+
const storeId = TagOrderStore.getStoreId();
return asyncAction('TagOrderActions.moveTag', () => {
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
return matrixClient.setAccountData(
'im.vector.web.tag_ordering',
- {tags, _storeId: storeId},
+ {tags, removedTags, _storeId: storeId},
);
}, () => {
// For an optimistic update
- return {tags};
+ return {tags, removedTags};
+ });
+};
+
+/**
+ * Creates an action thunk that will do an asynchronous request to
+ * label a tag as removed in im.vector.web.tag_ordering account data.
+ *
+ * The reason this is implemented with new state `removedTags` is that
+ * we incrementally and initially populate `tags` with groups that
+ * have been joined. If we remove a group from `tags`, it will just
+ * get added (as it looks like a group we've recently joined).
+ *
+ * NB: If we ever support adding of tags (which is planned), we should
+ * take special care to remove the tag from `removedTags` when we add
+ * it.
+ *
+ * @param {MatrixClient} matrixClient the matrix client to set the
+ * account data on.
+ * @param {string} tag the tag to remove.
+ * @returns {function} an action thunk that will dispatch actions
+ * indicating the status of the request.
+ * @see asyncAction
+ */
+TagOrderActions.removeTag = function(matrixClient, tag) {
+ // Don't change tags, just removedTags
+ const tags = TagOrderStore.getOrderedTags();
+ const removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
+
+ if (removedTags.includes(tag)) {
+ // Return a thunk that doesn't do anything, we don't even need
+ // an asynchronous action here, the tag is already removed.
+ return () => {};
+ }
+
+ removedTags.push(tag);
+
+ const storeId = TagOrderStore.getStoreId();
+
+ return asyncAction('TagOrderActions.removeTag', () => {
+ Analytics.trackEvent('TagOrderActions', 'removeTag');
+ return matrixClient.setAccountData(
+ 'im.vector.web.tag_ordering',
+ {tags, removedTags, _storeId: storeId},
+ );
+ }, () => {
+ // For an optimistic update
+ return {removedTags};
});
};
diff --git a/src/actions/actionCreators.js b/src/actions/actionCreators.js
index 0238eee8c0..967ce609e7 100644
--- a/src/actions/actionCreators.js
+++ b/src/actions/actionCreators.js
@@ -31,6 +31,15 @@ limitations under the License.
* `${id}.pending` and either
* `${id}.success` or
* `${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) {
return (dispatch) => {
diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js
index fefe77f6cd..bceec3f144 100644
--- a/src/autocomplete/UserProvider.js
+++ b/src/autocomplete/UserProvider.js
@@ -158,7 +158,7 @@ export default class UserProvider extends AutocompleteProvider {
}
renderCompletions(completions: [React.Component]): ?React.Component {
- return
+ return
{ completions }
;
}
diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js
index e97d9dd0a1..f6bbfd247b 100644
--- a/src/components/structures/LoggedInView.js
+++ b/src/components/structures/LoggedInView.js
@@ -19,6 +19,7 @@ limitations under the License.
import * as Matrix from 'matrix-js-sdk';
import React from 'react';
import PropTypes from 'prop-types';
+import { DragDropContext } from 'react-beautiful-dnd';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import Notifier from '../../Notifier';
@@ -30,6 +31,9 @@ import sessionStore from '../../stores/SessionStore';
import MatrixClientPeg from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
+import TagOrderActions from '../../actions/TagOrderActions';
+import RoomListActions from '../../actions/RoomListActions';
+
/**
* This is what our MatrixChat shows when we are logged in. The precise view is
* determined by the page_type property.
@@ -207,8 +211,51 @@ const LoggedInView = React.createClass({
}
},
+ _onDragEnd: function(result) {
+ // Dragged to an invalid destination, not onto a droppable
+ if (!result.destination) {
+ return;
+ }
+
+ const dest = result.destination.droppableId;
+
+ if (dest === 'tag-panel-droppable') {
+ // Could be "GroupTile +groupId:domain"
+ const draggableId = result.draggableId.split(' ').pop();
+
+ // Dispatch synchronously so that the TagPanel receives an
+ // optimistic update from TagOrderStore before the previous
+ // state is shown.
+ dis.dispatch(TagOrderActions.moveTag(
+ this._matrixClient,
+ draggableId,
+ result.destination.index,
+ ), true);
+ } else if (dest.startsWith('room-sub-list-droppable_')) {
+ this._onRoomTileEndDrag(result);
+ }
+ },
+
+ _onRoomTileEndDrag: function(result) {
+ 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 oldIndex = result.source.index;
+ const newIndex = result.destination.index;
+
+ dis.dispatch(RoomListActions.tagRoom(
+ this._matrixClient,
+ this._matrixClient.getRoom(roomId),
+ prevTag, newTag,
+ oldIndex, newIndex,
+ ), true);
+ },
+
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');
@@ -329,17 +376,18 @@ const LoggedInView = React.createClass({
return (
{ topBar }
-
- { SettingsStore.isFeatureEnabled("feature_tag_panel") ?
:
}
-
-
- { page_element }
-
- { right_panel }
-
+
+
+
+
+ { page_element }
+
+ { right_panel }
+
+
);
},
diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js
index 22157beaca..116607fb08 100644
--- a/src/components/structures/MyGroups.js
+++ b/src/components/structures/MyGroups.js
@@ -73,8 +73,10 @@ export default withMatrixClient(React.createClass({
});
contentHeader = groupNodes.length > 0 ?
{ _t('Your Communities') }
:
;
content = groupNodes.length > 0 ?
-
- { groupNodes }
+
+
+ { groupNodes }
+
:
{ _t(
diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js
index 49a7a4020a..6e3bcf521b 100644
--- a/src/components/structures/TagPanel.js
+++ b/src/components/structures/TagPanel.js
@@ -20,12 +20,11 @@ import { MatrixClient } from 'matrix-js-sdk';
import TagOrderStore from '../../stores/TagOrderStore';
import GroupActions from '../../actions/GroupActions';
-import TagOrderActions from '../../actions/TagOrderActions';
import sdk from '../../index';
import dis from '../../dispatcher';
-import { DragDropContext, Droppable } from 'react-beautiful-dnd';
+import { Droppable } from 'react-beautiful-dnd';
const TagPanel = React.createClass({
displayName: 'TagPanel',
@@ -94,25 +93,8 @@ const TagPanel = React.createClass({
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() {
- const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
- const TintableSvg = sdk.getComponent('elements.TintableSvg');
+ const GroupsButton = sdk.getComponent('elements.GroupsButton');
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const tags = this.state.orderedTags.map((tag, index) => {
@@ -124,27 +106,28 @@ const TagPanel = React.createClass({
/>;
});
return
-
-
- { (provided, snapshot) => (
-
- { tags }
- { provided.placeholder }
-
- ) }
-
-
-
-
-
+
+ { (provided, snapshot) => (
+
+ { tags }
+ { provided.placeholder }
+
+ ) }
+
+
+
+
;
},
});
diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js
index 4ade78af85..12f745146e 100644
--- a/src/components/structures/TimelinePanel.js
+++ b/src/components/structures/TimelinePanel.js
@@ -1121,9 +1121,9 @@ var TimelinePanel = React.createClass({
// exist.
if (this.state.timelineLoading) {
return (
-
-
-
+
+
+
);
}
diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js
index 5042ca1fd0..7f4aa0325a 100644
--- a/src/components/structures/login/Login.js
+++ b/src/components/structures/login/Login.js
@@ -431,10 +431,10 @@ module.exports = React.createClass({
// FIXME: remove status.im theme tweaks
const theme = SettingsStore.getValue("theme");
if (theme !== "status") {
- header =
{ _t('Sign in') }
;
+ header =
{ _t('Sign in') } { loader }
;
} else {
if (!this.state.errorText) {
- header =
{ _t('Sign in to get started') }
;
+ header =
{ _t('Sign in to get started') } { loader }
;
}
}
diff --git a/src/components/views/elements/DNDTagTile.js b/src/components/views/elements/DNDTagTile.js
index e17ea52976..9d8ecc1da6 100644
--- a/src/components/views/elements/DNDTagTile.js
+++ b/src/components/views/elements/DNDTagTile.js
@@ -25,6 +25,7 @@ export default function DNDTagTile(props) {
key={props.tag}
draggableId={props.tag}
index={props.index}
+ type="draggable-TagTile"
>
{ (provided, snapshot) => (
diff --git a/src/components/views/elements/TagTile.js b/src/components/views/elements/TagTile.js
index f52f758cc0..8d801d986d 100644
--- a/src/components/views/elements/TagTile.js
+++ b/src/components/views/elements/TagTile.js
@@ -21,6 +21,7 @@ import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard';
+import ContextualMenu from '../../structures/ContextualMenu';
import FlairStore from '../../../stores/FlairStore';
@@ -81,6 +82,35 @@ export default React.createClass({
});
},
+ onContextButtonClick: function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Hide the (...) immediately
+ this.setState({ hover: false });
+
+ const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu');
+ const elementRect = e.target.getBoundingClientRect();
+
+ // The window X and Y offsets are to adjust position when zoomed in to page
+ const x = elementRect.right + window.pageXOffset + 3;
+ const chevronOffset = 12;
+ let y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset);
+ y = y - (chevronOffset + 8); // where 8 is half the height of the chevron
+
+ const self = this;
+ ContextualMenu.createMenu(TagTileContextMenu, {
+ chevronOffset: chevronOffset,
+ left: x,
+ top: y,
+ tag: this.props.tag,
+ onFinished: function() {
+ self.setState({ menuDisplayed: false });
+ },
+ });
+ this.setState({ menuDisplayed: true });
+ },
+
onMouseOver: function() {
this.setState({hover: true});
},
@@ -109,10 +139,15 @@ export default React.createClass({
const tip = this.state.hover ?
:
;
+ const contextButton = this.state.hover || this.state.menuDisplayed ?
+
+ { "\u00B7\u00B7\u00B7" }
+
:
;
return
{ tip }
+ { contextButton }
;
},
diff --git a/src/components/views/groups/GroupTile.js b/src/components/views/groups/GroupTile.js
index ce426a9b78..f1dbb75988 100644
--- a/src/components/views/groups/GroupTile.js
+++ b/src/components/views/groups/GroupTile.js
@@ -17,10 +17,12 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {MatrixClient} from 'matrix-js-sdk';
+import { Draggable, Droppable } from 'react-beautiful-dnd';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import FlairStore from '../../../stores/FlairStore';
+
const GroupTile = React.createClass({
displayName: 'GroupTile',
@@ -78,9 +80,39 @@ const GroupTile = React.createClass({
profile.avatarUrl, avatarHeight, avatarHeight, "crop",
) : null;
return
-
-
-
+
+ { (droppableProvided, droppableSnapshot) => (
+
+
+ { (provided, snapshot) => (
+
+
+ { /* Instead of a blank placeholder, use a copy of the avatar itself. */ }
+ { provided.placeholder ?
+
+
+
:
+
+ }
+
+ ) }
+
+
+ ) }
+
{ name }
{ descElement }
diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js
index c324c291e7..90efe24df3 100644
--- a/src/components/views/messages/MFileBody.js
+++ b/src/components/views/messages/MFileBody.js
@@ -82,7 +82,7 @@ Tinter.registerTintable(updateTintedDownloadImage);
// downloaded. This limit does not seem to apply when the url is used as
// the source attribute of an image tag.
//
-// Blob URLs are generated using window.URL.createObjectURL and unforuntately
+// Blob URLs are generated using window.URL.createObjectURL and unfortunately
// for our purposes they inherit the origin of the page that created them.
// This means that any scripts that run when the URL is viewed will be able
// to access local storage.
diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js
index 0c0f7366eb..41a200420d 100644
--- a/src/components/views/rooms/RoomList.js
+++ b/src/components/views/rooms/RoomList.js
@@ -18,7 +18,6 @@ limitations under the License.
'use strict';
const React = require("react");
const ReactDOM = require("react-dom");
-import { DragDropContext } from 'react-beautiful-dnd';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
const GeminiScrollbar = require('react-gemini-scrollbar');
@@ -27,14 +26,13 @@ const CallHandler = require('../../../CallHandler');
const dis = require("../../../dispatcher");
const sdk = require('../../../index');
const rate_limited_func = require('../../../ratelimitedfunc');
-const Rooms = require('../../../Rooms');
+import * as Rooms from '../../../Rooms';
import DMRoomMap from '../../../utils/DMRoomMap';
const Receipt = require('../../../utils/Receipt');
import TagOrderStore from '../../../stores/TagOrderStore';
+import RoomListStore from '../../../stores/RoomListStore';
import GroupStoreCache from '../../../stores/GroupStoreCache';
-import Modal from '../../../Modal';
-
const HIDE_CONFERENCE_CHANS = true;
function phraseForSection(section) {
@@ -80,7 +78,6 @@ module.exports = React.createClass({
cli.on("deleteRoom", this.onDeleteRoom);
cli.on("Room.timeline", this.onRoomTimeline);
cli.on("Room.name", this.onRoomName);
- cli.on("Room.tags", this.onRoomTags);
cli.on("Room.receipt", this.onRoomReceipt);
cli.on("RoomState.events", this.onRoomStateEvents);
cli.on("RoomMember.name", this.onRoomMemberName);
@@ -118,6 +115,10 @@ module.exports = React.createClass({
this.updateVisibleRooms();
});
+ this._roomListStoreToken = RoomListStore.addListener(() => {
+ this._delayedRefreshRoomList();
+ });
+
this.refreshRoomList();
// order of the sublists
@@ -178,7 +179,6 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("deleteRoom", this.onDeleteRoom);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
- MatrixClientPeg.get().removeListener("Room.tags", this.onRoomTags);
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName);
@@ -191,6 +191,10 @@ module.exports = React.createClass({
this._tagStoreToken.remove();
}
+ if (this._roomListStoreToken) {
+ this._roomListStoreToken.remove();
+ }
+
if (this._groupStoreTokens.length > 0) {
// NB: GroupStore is not a Flux.Store
this._groupStoreTokens.forEach((token) => token.unregister());
@@ -251,10 +255,6 @@ module.exports = React.createClass({
this._delayedRefreshRoomList();
},
- onRoomTags: function(event, room) {
- this._delayedRefreshRoomList();
- },
-
onRoomStateEvents: function(ev, state) {
this._delayedRefreshRoomList();
},
@@ -278,106 +278,6 @@ module.exports = React.createClass({
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() {
this.refreshRoomList();
}, 500),
@@ -441,7 +341,7 @@ module.exports = React.createClass({
totalRooms += l.length;
}
this.setState({
- lists: this.getRoomLists(),
+ lists,
totalRoomCount: totalRooms,
// Do this here so as to not render every time the selected tags
// themselves change.
@@ -452,70 +352,34 @@ module.exports = React.createClass({
},
getRoomLists: function() {
- const lists = {};
- 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 lists = RoomListStore.getRoomLists();
- const dmRoomMap = DMRoomMap.shared();
+ const filteredLists = {};
- this._visibleRooms.forEach((room, index) => {
- const me = room.getMember(MatrixClientPeg.get().credentials.userId);
- if (!me) return;
+ const isRoomVisible = {
+ // $roomId: true,
+ };
- // console.log("room = " + room.name + ", me.membership = " + me.membership +
- // ", sender = " + me.events.member.getSender() +
- // ", 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");
- }
+ this._visibleRooms.forEach((r) => {
+ isRoomVisible[r.roomId] = true;
});
- // 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
-/*
- 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 Boolean(isRoomVisible[taggedRoom.roomId]);
+ });
+ });
- return lists;
+ return filteredLists;
},
_getScrollNode: function() {
@@ -752,116 +616,114 @@ module.exports = React.createClass({
const self = this;
return (
-
-
-
-
+
+
+
-
+
-
+
-
+
-
+
- { Object.keys(self.state.lists).map((tagName) => {
- if (!tagName.match(/^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/)) {
- return ;
- }
- }) }
+ { Object.keys(self.state.lists).map((tagName) => {
+ if (!tagName.match(/^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/)) {
+ return ;
+ }
+ }) }
-
+
-
-
-
-
+
+
+
);
},
});
diff --git a/src/components/views/rooms/RoomNameEditor.js b/src/components/views/rooms/RoomNameEditor.js
index 5c224d79c0..d073a0be25 100644
--- a/src/components/views/rooms/RoomNameEditor.js
+++ b/src/components/views/rooms/RoomNameEditor.js
@@ -29,13 +29,21 @@ module.exports = React.createClass({
room: PropTypes.object.isRequired,
},
+ getInitialState: function() {
+ return {
+ name: null,
+ };
+ },
+
componentWillMount: function() {
const room = this.props.room;
const name = room.currentState.getStateEvents('m.room.name', '');
const myId = MatrixClientPeg.get().credentials.userId;
const defaultName = room.getDefaultRoomName(myId);
- this._initialName = name ? name.getContent().name : '';
+ this.setState({
+ name: name ? name.getContent().name : '',
+ });
this._placeholderName = _t("Unnamed Room");
if (defaultName && defaultName !== 'Empty room') { // default name from JS SDK, needs no translation as we don't ever show it.
@@ -44,7 +52,13 @@ module.exports = React.createClass({
},
getRoomName: function() {
- return this.refs.editor.getValue();
+ return this.state.name;
+ },
+
+ _onValueChanged: function(value, shouldSubmit) {
+ this.setState({
+ name: value,
+ });
},
render: function() {
@@ -57,7 +71,8 @@ module.exports = React.createClass({
placeholderClassName="mx_RoomHeader_placeholder"
placeholder={this._placeholderName}
blurToCancel={false}
- initialValue={this._initialName}
+ initialValue={this.state.name}
+ onValueChanged={this._onValueChanged}
dir="auto" />
);
diff --git a/src/components/views/rooms/RoomTopicEditor.js b/src/components/views/rooms/RoomTopicEditor.js
index 8f950d625c..7ad02f264c 100644
--- a/src/components/views/rooms/RoomTopicEditor.js
+++ b/src/components/views/rooms/RoomTopicEditor.js
@@ -28,26 +28,41 @@ module.exports = React.createClass({
room: PropTypes.object.isRequired,
},
+ getInitialState: function() {
+ return {
+ topic: null,
+ };
+ },
+
componentWillMount: function() {
const room = this.props.room;
const topic = room.currentState.getStateEvents('m.room.topic', '');
- this._initialTopic = topic ? topic.getContent().topic : '';
+ this.setState({
+ topic: topic ? topic.getContent().topic : '',
+ });
},
getTopic: function() {
- return this.refs.editor.getValue();
+ return this.state.topic;
+ },
+
+ _onValueChanged: function(value) {
+ this.setState({
+ topic: value,
+ });
},
render: function() {
const EditableText = sdk.getComponent("elements.EditableText");
return (
-
);
},
diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js
new file mode 100644
index 0000000000..fdd9ca6692
--- /dev/null
+++ b/src/stores/RoomListStore.js
@@ -0,0 +1,251 @@
+/*
+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 = {
+ "m.favourite": "manual",
+ "im.vector.fake.invite": "recent",
+ "im.vector.fake.recent": "recent",
+ "im.vector.fake.direct": "recent",
+ "m.lowpriority": "recent",
+ "im.vector.fake.archived": "recent",
+ };
+
+ Object.keys(lists).forEach((listKey) => {
+ let comparator;
+ switch (listOrders[listKey]) {
+ case "recent":
+ comparator = this._recentsComparator;
+ break;
+ case "manual":
+ default:
+ comparator = this._getManualComparator(listKey, optimisticRequest);
+ 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;
diff --git a/src/stores/TagOrderStore.js b/src/stores/TagOrderStore.js
index 69b22797fb..eef078d8da 100644
--- a/src/stores/TagOrderStore.js
+++ b/src/stores/TagOrderStore.js
@@ -55,6 +55,7 @@ class TagOrderStore extends Store {
const tagOrderingEventContent = tagOrderingEvent ? tagOrderingEvent.getContent() : {};
this._setState({
orderedTagsAccountData: tagOrderingEventContent.tags || null,
+ removedTagsAccountData: tagOrderingEventContent.removedTags || null,
hasSynced: true,
});
this._updateOrderedTags();
@@ -70,6 +71,7 @@ class TagOrderStore extends Store {
this._setState({
orderedTagsAccountData: payload.event_content ? payload.event_content.tags : null,
+ removedTagsAccountData: payload.event_content ? payload.event_content.removedTags : null,
});
this._updateOrderedTags();
break;
@@ -87,9 +89,18 @@ class TagOrderStore extends Store {
// Optimistic update of a moved tag
this._setState({
orderedTags: payload.request.tags,
+ removedTagsAccountData: payload.request.removedTags,
});
break;
}
+ case 'TagOrderActions.removeTag.pending': {
+ // Optimistic update of a removed tag
+ this._setState({
+ removedTagsAccountData: payload.request.removedTags,
+ });
+ this._updateOrderedTags();
+ break;
+ }
case 'select_tag': {
let newTags = [];
// Shift-click semantics
@@ -165,13 +176,15 @@ class TagOrderStore extends Store {
_mergeGroupsAndTags() {
const groupIds = this._state.joinedGroupIds || [];
const tags = this._state.orderedTagsAccountData || [];
+ const removedTags = new Set(this._state.removedTagsAccountData || []);
+
const tagsToKeep = tags.filter(
- (t) => t[0] !== '+' || groupIds.includes(t),
+ (t) => (t[0] !== '+' || groupIds.includes(t)) && !removedTags.has(t),
);
const groupIdsToAdd = groupIds.filter(
- (groupId) => !tags.includes(groupId),
+ (groupId) => !tags.includes(groupId) && !removedTags.has(groupId),
);
return tagsToKeep.concat(groupIdsToAdd);
@@ -181,6 +194,10 @@ class TagOrderStore extends Store {
return this._state.orderedTags;
}
+ getRemovedTagsAccountData() {
+ return this._state.removedTagsAccountData;
+ }
+
getStoreId() {
// Generate a random ID to prevent this store from clobbering its
// state with redundant remote echos.