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.