Merge branches 'develop' and 't3chguy/m.relates_to' of github.com:matrix-org/matrix-react-sdk into t3chguy/m.relates_to

This commit is contained in:
Michael Telatynski 2018-02-16 14:35:04 +00:00
commit 2e3cbb309e
No known key found for this signature in database
GPG key ID: 3F879DA5AD802A5E
20 changed files with 837 additions and 350 deletions

View file

@ -50,11 +50,15 @@ function pad(n) {
return (n < 10 ? '0' : '') + n; return (n < 10 ? '0' : '') + n;
} }
function twelveHourTime(date) { function twelveHourTime(date, showSeconds=false) {
let hours = date.getHours() % 12; let hours = date.getHours() % 12;
const minutes = pad(date.getMinutes()); const minutes = pad(date.getMinutes());
const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM'); const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM');
hours = hours ? hours : 12; // convert 0 -> 12 hours = hours ? hours : 12; // convert 0 -> 12
if (showSeconds) {
const seconds = pad(date.getSeconds());
return `${hours}:${minutes}:${seconds}${ampm}`;
}
return `${hours}:${minutes}${ampm}`; return `${hours}:${minutes}${ampm}`;
} }
@ -101,10 +105,17 @@ export function formatFullDate(date, showTwelveHour=false) {
monthName: months[date.getMonth()], monthName: months[date.getMonth()],
day: date.getDate(), day: date.getDate(),
fullYear: date.getFullYear(), 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) { export function formatTime(date, showTwelveHour=false) {
if (showTwelveHour) { if (showTwelveHour) {
return twelveHourTime(date); return twelveHourTime(date);

View file

@ -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 * This object is responsible for dispatching actions when certain events are emitted by
* the given MatrixClient. * the given MatrixClient.
@ -78,6 +86,8 @@ export default {
start(matrixClient) { start(matrixClient) {
this._addMatrixClientListener(matrixClient, 'sync', createSyncAction); this._addMatrixClientListener(matrixClient, 'sync', createSyncAction);
this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); 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) { _addMatrixClientListener(matrixClient, eventName, actionCreator) {
const listener = (...args) => { const listener = (...args) => {
dis.dispatch(actionCreator(matrixClient, ...args)); dis.dispatch(actionCreator(matrixClient, ...args), true);
}; };
matrixClient.on(eventName, listener); matrixClient.on(eventName, listener);
this._matrixClientListenersStop.push(() => { this._matrixClientListenersStop.push(() => {

View file

@ -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;

View file

@ -35,6 +35,7 @@ const TagOrderActions = {};
TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) { TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) {
// Only commit tags if the state is ready, i.e. not null // Only commit tags if the state is ready, i.e. not null
let tags = TagOrderStore.getOrderedTags(); let tags = TagOrderStore.getOrderedTags();
let removedTags = TagOrderStore.getRemovedTagsAccountData();
if (!tags) { if (!tags) {
return; return;
} }
@ -42,17 +43,66 @@ TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) {
tags = tags.filter((t) => t !== tag); tags = tags.filter((t) => t !== tag);
tags = [...tags.slice(0, destinationIx), tag, ...tags.slice(destinationIx)]; tags = [...tags.slice(0, destinationIx), tag, ...tags.slice(destinationIx)];
removedTags = removedTags.filter((t) => t !== tag);
const storeId = TagOrderStore.getStoreId(); const storeId = TagOrderStore.getStoreId();
return asyncAction('TagOrderActions.moveTag', () => { return asyncAction('TagOrderActions.moveTag', () => {
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering'); Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
return matrixClient.setAccountData( return matrixClient.setAccountData(
'im.vector.web.tag_ordering', 'im.vector.web.tag_ordering',
{tags, _storeId: storeId}, {tags, removedTags, _storeId: storeId},
); );
}, () => { }, () => {
// For an optimistic update // 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};
}); });
}; };

View file

@ -31,6 +31,15 @@ limitations under the License.
* `${id}.pending` and either * `${id}.pending` and either
* `${id}.success` or * `${id}.success` or
* `${id}.failure`. * `${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) { export function asyncAction(id, fn, pendingFn) {
return (dispatch) => { return (dispatch) => {

View file

@ -158,7 +158,7 @@ export default class UserProvider extends AutocompleteProvider {
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"> return <div className="mx_Autocomplete_Completion_container_pill">
{ completions } { completions }
</div>; </div>;
} }

View file

@ -19,6 +19,7 @@ limitations under the License.
import * as Matrix from 'matrix-js-sdk'; import * as Matrix from 'matrix-js-sdk';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { DragDropContext } from 'react-beautiful-dnd';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import Notifier from '../../Notifier'; import Notifier from '../../Notifier';
@ -30,6 +31,9 @@ import sessionStore from '../../stores/SessionStore';
import MatrixClientPeg from '../../MatrixClientPeg'; import MatrixClientPeg from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore"; 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 * This is what our MatrixChat shows when we are logged in. The precise view is
* determined by the page_type property. * 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() { render: function() {
const TagPanel = sdk.getComponent('structures.TagPanel');
const LeftPanel = sdk.getComponent('structures.LeftPanel'); const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RightPanel = sdk.getComponent('structures.RightPanel'); const RightPanel = sdk.getComponent('structures.RightPanel');
const RoomView = sdk.getComponent('structures.RoomView'); const RoomView = sdk.getComponent('structures.RoomView');
@ -329,17 +376,18 @@ const LoggedInView = React.createClass({
return ( return (
<div className='mx_MatrixChat_wrapper'> <div className='mx_MatrixChat_wrapper'>
{ topBar } { topBar }
<div className={bodyClasses}> <DragDropContext onDragEnd={this._onDragEnd}>
{ SettingsStore.isFeatureEnabled("feature_tag_panel") ? <TagPanel /> : <div /> } <div className={bodyClasses}>
<LeftPanel <LeftPanel
collapsed={this.props.collapseLhs || false} collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled} disabled={this.props.leftDisabled}
/> />
<main className='mx_MatrixChat_middlePanel'> <main className='mx_MatrixChat_middlePanel'>
{ page_element } { page_element }
</main> </main>
{ right_panel } { right_panel }
</div> </div>
</DragDropContext>
</div> </div>
); );
}, },

View file

@ -73,8 +73,10 @@ export default withMatrixClient(React.createClass({
}); });
contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />; contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />;
content = groupNodes.length > 0 ? content = groupNodes.length > 0 ?
<GeminiScrollbar className="mx_MyGroups_joinedGroups"> <GeminiScrollbar>
{ groupNodes } <div className="mx_MyGroups_joinedGroups">
{ groupNodes }
</div>
</GeminiScrollbar> : </GeminiScrollbar> :
<div className="mx_MyGroups_placeholder"> <div className="mx_MyGroups_placeholder">
{ _t( { _t(

View file

@ -20,12 +20,11 @@ import { MatrixClient } from 'matrix-js-sdk';
import TagOrderStore from '../../stores/TagOrderStore'; import TagOrderStore from '../../stores/TagOrderStore';
import GroupActions from '../../actions/GroupActions'; import GroupActions from '../../actions/GroupActions';
import TagOrderActions from '../../actions/TagOrderActions';
import sdk from '../../index'; import sdk from '../../index';
import dis from '../../dispatcher'; import dis from '../../dispatcher';
import { DragDropContext, Droppable } from 'react-beautiful-dnd'; import { Droppable } from 'react-beautiful-dnd';
const TagPanel = React.createClass({ const TagPanel = React.createClass({
displayName: 'TagPanel', displayName: 'TagPanel',
@ -94,25 +93,8 @@ const TagPanel = React.createClass({
dis.dispatch({action: 'view_create_group'}); 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() { render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const GroupsButton = sdk.getComponent('elements.GroupsButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const DNDTagTile = sdk.getComponent('elements.DNDTagTile'); const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const tags = this.state.orderedTags.map((tag, index) => { const tags = this.state.orderedTags.map((tag, index) => {
@ -124,27 +106,28 @@ const TagPanel = React.createClass({
/>; />;
}); });
return <div className="mx_TagPanel"> return <div className="mx_TagPanel">
<DragDropContext onDragEnd={this.onTagTileEndDrag}> <Droppable
<Droppable droppableId="tag-panel-droppable"> droppableId="tag-panel-droppable"
{ (provided, snapshot) => ( type="draggable-TagTile"
<div >
className="mx_TagPanel_tagTileContainer" { (provided, snapshot) => (
ref={provided.innerRef} <div
// react-beautiful-dnd has a bug that emits a click to the parent className="mx_TagPanel_tagTileContainer"
// of draggables upon dropping ref={provided.innerRef}
// https://github.com/atlassian/react-beautiful-dnd/issues/273 // react-beautiful-dnd has a bug that emits a click to the parent
// so we use onMouseDown here as a workaround. // of draggables upon dropping
onMouseDown={this.onClick} // https://github.com/atlassian/react-beautiful-dnd/issues/273
> // so we use onMouseDown here as a workaround.
{ tags } onMouseDown={this.onClick}
{ provided.placeholder } >
</div> { tags }
) } { provided.placeholder }
</Droppable> </div>
</DragDropContext> ) }
<AccessibleButton className="mx_TagPanel_createGroupButton" onClick={this.onCreateGroupClick}> </Droppable>
<TintableSvg src="img/icons-create-room.svg" width="25" height="25" /> <div className="mx_TagPanel_createGroupButton">
</AccessibleButton> <GroupsButton tooltip={true} />
</div>
</div>; </div>;
}, },
}); });

View file

@ -1121,9 +1121,9 @@ var TimelinePanel = React.createClass({
// exist. // exist.
if (this.state.timelineLoading) { if (this.state.timelineLoading) {
return ( return (
<div className={this.props.className + " mx_RoomView_messageListWrapper"}> <div className="mx_RoomView_messagePanelSpinner">
<Loader /> <Loader />
</div> </div>
); );
} }

View file

@ -431,10 +431,10 @@ module.exports = React.createClass({
// FIXME: remove status.im theme tweaks // FIXME: remove status.im theme tweaks
const theme = SettingsStore.getValue("theme"); const theme = SettingsStore.getValue("theme");
if (theme !== "status") { if (theme !== "status") {
header = <h2>{ _t('Sign in') }</h2>; header = <h2>{ _t('Sign in') } { loader }</h2>;
} else { } else {
if (!this.state.errorText) { if (!this.state.errorText) {
header = <h2>{ _t('Sign in to get started') }</h2>; header = <h2>{ _t('Sign in to get started') } { loader }</h2>;
} }
} }

View file

@ -25,6 +25,7 @@ export default function DNDTagTile(props) {
key={props.tag} key={props.tag}
draggableId={props.tag} draggableId={props.tag}
index={props.index} index={props.index}
type="draggable-TagTile"
> >
{ (provided, snapshot) => ( { (provided, snapshot) => (
<div> <div>

View file

@ -21,6 +21,7 @@ import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../../index'; import sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard'; import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard';
import ContextualMenu from '../../structures/ContextualMenu';
import FlairStore from '../../../stores/FlairStore'; 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() { onMouseOver: function() {
this.setState({hover: true}); this.setState({hover: true});
}, },
@ -109,10 +139,15 @@ export default React.createClass({
const tip = this.state.hover ? const tip = this.state.hover ?
<RoomTooltip className="mx_TagTile_tooltip" label={name} /> : <RoomTooltip className="mx_TagTile_tooltip" label={name} /> :
<div />; <div />;
const contextButton = this.state.hover || this.state.menuDisplayed ?
<div className="mx_TagTile_context_button" onClick={this.onContextButtonClick}>
{ "\u00B7\u00B7\u00B7" }
</div> : <div />;
return <AccessibleButton className={className} onClick={this.onClick}> return <AccessibleButton className={className} onClick={this.onClick}>
<div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}> <div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}>
<BaseAvatar name={name} url={httpUrl} width={avatarHeight} height={avatarHeight} /> <BaseAvatar name={name} url={httpUrl} width={avatarHeight} height={avatarHeight} />
{ tip } { tip }
{ contextButton }
</div> </div>
</AccessibleButton>; </AccessibleButton>;
}, },

View file

@ -17,10 +17,12 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {MatrixClient} from 'matrix-js-sdk'; import {MatrixClient} from 'matrix-js-sdk';
import { Draggable, Droppable } from 'react-beautiful-dnd';
import sdk from '../../../index'; import sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import FlairStore from '../../../stores/FlairStore'; import FlairStore from '../../../stores/FlairStore';
const GroupTile = React.createClass({ const GroupTile = React.createClass({
displayName: 'GroupTile', displayName: 'GroupTile',
@ -78,9 +80,39 @@ const GroupTile = React.createClass({
profile.avatarUrl, avatarHeight, avatarHeight, "crop", profile.avatarUrl, avatarHeight, avatarHeight, "crop",
) : null; ) : null;
return <AccessibleButton className="mx_GroupTile" onClick={this.onClick}> return <AccessibleButton className="mx_GroupTile" onClick={this.onClick}>
<div className="mx_GroupTile_avatar"> <Droppable droppableId="my-groups-droppable" type="draggable-TagTile">
<BaseAvatar name={name} url={httpUrl} width={avatarHeight} height={avatarHeight} /> { (droppableProvided, droppableSnapshot) => (
</div> <div ref={droppableProvided.innerRef}>
<Draggable
key={"GroupTile " + this.props.groupId}
draggableId={"GroupTile " + this.props.groupId}
index={this.props.groupId}
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div>
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className="mx_GroupTile_avatar">
<BaseAvatar name={name} url={httpUrl} width={avatarHeight} height={avatarHeight} />
</div>
</div>
{ /* Instead of a blank placeholder, use a copy of the avatar itself. */ }
{ provided.placeholder ?
<div className="mx_GroupTile_avatar">
<BaseAvatar name={name} url={httpUrl} width={avatarHeight} height={avatarHeight} />
</div> :
<div />
}
</div>
) }
</Draggable>
</div>
) }
</Droppable>
<div className="mx_GroupTile_profile"> <div className="mx_GroupTile_profile">
<div className="mx_GroupTile_name">{ name }</div> <div className="mx_GroupTile_name">{ name }</div>
{ descElement } { descElement }

View file

@ -82,7 +82,7 @@ Tinter.registerTintable(updateTintedDownloadImage);
// downloaded. This limit does not seem to apply when the url is used as // downloaded. This limit does not seem to apply when the url is used as
// the source attribute of an image tag. // 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. // 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 // This means that any scripts that run when the URL is viewed will be able
// to access local storage. // to access local storage.

View file

@ -18,7 +18,6 @@ limitations under the License.
'use strict'; 'use strict';
const React = require("react"); const React = require("react");
const ReactDOM = require("react-dom"); const ReactDOM = require("react-dom");
import { DragDropContext } from 'react-beautiful-dnd';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
const GeminiScrollbar = require('react-gemini-scrollbar'); const GeminiScrollbar = require('react-gemini-scrollbar');
@ -27,14 +26,13 @@ const CallHandler = require('../../../CallHandler');
const dis = require("../../../dispatcher"); const dis = require("../../../dispatcher");
const sdk = require('../../../index'); const sdk = require('../../../index');
const rate_limited_func = require('../../../ratelimitedfunc'); const rate_limited_func = require('../../../ratelimitedfunc');
const Rooms = require('../../../Rooms'); import * as Rooms from '../../../Rooms';
import DMRoomMap from '../../../utils/DMRoomMap'; import DMRoomMap from '../../../utils/DMRoomMap';
const Receipt = require('../../../utils/Receipt'); const Receipt = require('../../../utils/Receipt');
import TagOrderStore from '../../../stores/TagOrderStore'; import TagOrderStore from '../../../stores/TagOrderStore';
import RoomListStore from '../../../stores/RoomListStore';
import GroupStoreCache from '../../../stores/GroupStoreCache'; import GroupStoreCache from '../../../stores/GroupStoreCache';
import Modal from '../../../Modal';
const HIDE_CONFERENCE_CHANS = true; const HIDE_CONFERENCE_CHANS = true;
function phraseForSection(section) { function phraseForSection(section) {
@ -80,7 +78,6 @@ module.exports = React.createClass({
cli.on("deleteRoom", this.onDeleteRoom); cli.on("deleteRoom", this.onDeleteRoom);
cli.on("Room.timeline", this.onRoomTimeline); cli.on("Room.timeline", this.onRoomTimeline);
cli.on("Room.name", this.onRoomName); cli.on("Room.name", this.onRoomName);
cli.on("Room.tags", this.onRoomTags);
cli.on("Room.receipt", this.onRoomReceipt); cli.on("Room.receipt", this.onRoomReceipt);
cli.on("RoomState.events", this.onRoomStateEvents); cli.on("RoomState.events", this.onRoomStateEvents);
cli.on("RoomMember.name", this.onRoomMemberName); cli.on("RoomMember.name", this.onRoomMemberName);
@ -118,6 +115,10 @@ module.exports = React.createClass({
this.updateVisibleRooms(); this.updateVisibleRooms();
}); });
this._roomListStoreToken = RoomListStore.addListener(() => {
this._delayedRefreshRoomList();
});
this.refreshRoomList(); this.refreshRoomList();
// order of the sublists // order of the sublists
@ -178,7 +179,6 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("deleteRoom", this.onDeleteRoom); MatrixClientPeg.get().removeListener("deleteRoom", this.onDeleteRoom);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.tags", this.onRoomTags);
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName); MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName);
@ -191,6 +191,10 @@ module.exports = React.createClass({
this._tagStoreToken.remove(); this._tagStoreToken.remove();
} }
if (this._roomListStoreToken) {
this._roomListStoreToken.remove();
}
if (this._groupStoreTokens.length > 0) { if (this._groupStoreTokens.length > 0) {
// NB: GroupStore is not a Flux.Store // NB: GroupStore is not a Flux.Store
this._groupStoreTokens.forEach((token) => token.unregister()); this._groupStoreTokens.forEach((token) => token.unregister());
@ -251,10 +255,6 @@ module.exports = React.createClass({
this._delayedRefreshRoomList(); this._delayedRefreshRoomList();
}, },
onRoomTags: function(event, room) {
this._delayedRefreshRoomList();
},
onRoomStateEvents: function(ev, state) { onRoomStateEvents: function(ev, state) {
this._delayedRefreshRoomList(); this._delayedRefreshRoomList();
}, },
@ -278,106 +278,6 @@ module.exports = React.createClass({
this.forceUpdate(); 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() { _delayedRefreshRoomList: new rate_limited_func(function() {
this.refreshRoomList(); this.refreshRoomList();
}, 500), }, 500),
@ -441,7 +341,7 @@ module.exports = React.createClass({
totalRooms += l.length; totalRooms += l.length;
} }
this.setState({ this.setState({
lists: this.getRoomLists(), lists,
totalRoomCount: totalRooms, totalRoomCount: totalRooms,
// Do this here so as to not render every time the selected tags // Do this here so as to not render every time the selected tags
// themselves change. // themselves change.
@ -452,70 +352,34 @@ module.exports = React.createClass({
}, },
getRoomLists: function() { getRoomLists: function() {
const lists = {}; const lists = RoomListStore.getRoomLists();
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 dmRoomMap = DMRoomMap.shared(); const filteredLists = {};
this._visibleRooms.forEach((room, index) => { const isRoomVisible = {
const me = room.getMember(MatrixClientPeg.get().credentials.userId); // $roomId: true,
if (!me) return; };
// console.log("room = " + room.name + ", me.membership = " + me.membership + this._visibleRooms.forEach((r) => {
// ", sender = " + me.events.member.getSender() + isRoomVisible[r.roomId] = true;
// ", 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");
}
}); });
// 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 return Boolean(isRoomVisible[taggedRoom.roomId]);
/* });
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 lists; return filteredLists;
}, },
_getScrollNode: function() { _getScrollNode: function() {
@ -752,116 +616,114 @@ module.exports = React.createClass({
const self = this; const self = this;
return ( return (
<DragDropContext onDragEnd={this.onRoomTileEndDrag}> <GeminiScrollbar className="mx_RoomList_scrollbar"
<GeminiScrollbar className="mx_RoomList_scrollbar" autoshow={true} onScroll={self._whenScrolling} ref="gemscroll">
autoshow={true} onScroll={self._whenScrolling} ref="gemscroll"> <div className="mx_RoomList">
<div className="mx_RoomList"> <RoomSubList list={[]}
<RoomSubList list={[]} extraTiles={this._makeGroupInviteTiles()}
extraTiles={this._makeGroupInviteTiles()} label={_t('Community Invites')}
label={_t('Community Invites')} editable={false}
editable={false} order="recent"
order="recent" isInvite={true}
isInvite={true} collapsed={self.props.collapsed}
collapsed={self.props.collapsed} searchFilter={self.props.searchFilter}
searchFilter={self.props.searchFilter} onHeaderClick={self.onSubListHeaderClick}
onHeaderClick={self.onSubListHeaderClick} onShowMoreRooms={self.onShowMoreRooms}
onShowMoreRooms={self.onShowMoreRooms} />
/>
<RoomSubList list={self.state.lists['im.vector.fake.invite']} <RoomSubList list={self.state.lists['im.vector.fake.invite']}
label={_t('Invites')} label={_t('Invites')}
editable={false} editable={false}
order="recent" order="recent"
isInvite={true} isInvite={true}
incomingCall={self.state.incomingCall} incomingCall={self.state.incomingCall}
collapsed={self.props.collapsed} collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter} searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick} onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms} onShowMoreRooms={self.onShowMoreRooms}
/> />
<RoomSubList list={self.state.lists['m.favourite']} <RoomSubList list={self.state.lists['m.favourite']}
label={_t('Favourites')} label={_t('Favourites')}
tagName="m.favourite" tagName="m.favourite"
emptyContent={this._getEmptyContent('m.favourite')} emptyContent={this._getEmptyContent('m.favourite')}
editable={true} editable={true}
order="manual" order="manual"
incomingCall={self.state.incomingCall} incomingCall={self.state.incomingCall}
collapsed={self.props.collapsed} collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter} searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick} onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms} /> onShowMoreRooms={self.onShowMoreRooms} />
<RoomSubList list={self.state.lists['im.vector.fake.direct']} <RoomSubList list={self.state.lists['im.vector.fake.direct']}
label={_t('People')} label={_t('People')}
tagName="im.vector.fake.direct" tagName="im.vector.fake.direct"
emptyContent={this._getEmptyContent('im.vector.fake.direct')} emptyContent={this._getEmptyContent('im.vector.fake.direct')}
headerItems={this._getHeaderItems('im.vector.fake.direct')} headerItems={this._getHeaderItems('im.vector.fake.direct')}
editable={true} editable={true}
order="recent" order="recent"
incomingCall={self.state.incomingCall} incomingCall={self.state.incomingCall}
collapsed={self.props.collapsed} collapsed={self.props.collapsed}
alwaysShowHeader={true} alwaysShowHeader={true}
searchFilter={self.props.searchFilter} searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick} onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms} /> onShowMoreRooms={self.onShowMoreRooms} />
<RoomSubList list={self.state.lists['im.vector.fake.recent']} <RoomSubList list={self.state.lists['im.vector.fake.recent']}
label={_t('Rooms')} label={_t('Rooms')}
editable={true} editable={true}
emptyContent={this._getEmptyContent('im.vector.fake.recent')} emptyContent={this._getEmptyContent('im.vector.fake.recent')}
headerItems={this._getHeaderItems('im.vector.fake.recent')} headerItems={this._getHeaderItems('im.vector.fake.recent')}
order="recent" order="recent"
incomingCall={self.state.incomingCall} incomingCall={self.state.incomingCall}
collapsed={self.props.collapsed} collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter} searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick} onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms} /> onShowMoreRooms={self.onShowMoreRooms} />
{ Object.keys(self.state.lists).map((tagName) => { { Object.keys(self.state.lists).map((tagName) => {
if (!tagName.match(/^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/)) { if (!tagName.match(/^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/)) {
return <RoomSubList list={self.state.lists[tagName]} return <RoomSubList list={self.state.lists[tagName]}
key={tagName} key={tagName}
label={tagName} label={tagName}
tagName={tagName} tagName={tagName}
emptyContent={this._getEmptyContent(tagName)} emptyContent={this._getEmptyContent(tagName)}
editable={true} editable={true}
order="manual" order="manual"
incomingCall={self.state.incomingCall} incomingCall={self.state.incomingCall}
collapsed={self.props.collapsed} collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter} searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick} onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms} />; onShowMoreRooms={self.onShowMoreRooms} />;
} }
}) } }) }
<RoomSubList list={self.state.lists['m.lowpriority']} <RoomSubList list={self.state.lists['m.lowpriority']}
label={_t('Low priority')} label={_t('Low priority')}
tagName="m.lowpriority" tagName="m.lowpriority"
emptyContent={this._getEmptyContent('m.lowpriority')} emptyContent={this._getEmptyContent('m.lowpriority')}
editable={true} editable={true}
order="recent" order="recent"
incomingCall={self.state.incomingCall} incomingCall={self.state.incomingCall}
collapsed={self.props.collapsed} collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter} searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick} onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms} /> onShowMoreRooms={self.onShowMoreRooms} />
<RoomSubList list={self.state.lists['im.vector.fake.archived']} <RoomSubList list={self.state.lists['im.vector.fake.archived']}
label={_t('Historical')} label={_t('Historical')}
editable={false} editable={false}
order="recent" order="recent"
collapsed={self.props.collapsed} collapsed={self.props.collapsed}
alwaysShowHeader={true} alwaysShowHeader={true}
startAsHidden={true} startAsHidden={true}
showSpinner={self.state.isLoadingLeftRooms} showSpinner={self.state.isLoadingLeftRooms}
onHeaderClick= {self.onArchivedHeaderClick} onHeaderClick= {self.onArchivedHeaderClick}
incomingCall={self.state.incomingCall} incomingCall={self.state.incomingCall}
searchFilter={self.props.searchFilter} searchFilter={self.props.searchFilter}
onShowMoreRooms={self.onShowMoreRooms} /> onShowMoreRooms={self.onShowMoreRooms} />
</div> </div>
</GeminiScrollbar> </GeminiScrollbar>
</DragDropContext>
); );
}, },
}); });

View file

@ -29,13 +29,21 @@ module.exports = React.createClass({
room: PropTypes.object.isRequired, room: PropTypes.object.isRequired,
}, },
getInitialState: function() {
return {
name: null,
};
},
componentWillMount: function() { componentWillMount: function() {
const room = this.props.room; const room = this.props.room;
const name = room.currentState.getStateEvents('m.room.name', ''); const name = room.currentState.getStateEvents('m.room.name', '');
const myId = MatrixClientPeg.get().credentials.userId; const myId = MatrixClientPeg.get().credentials.userId;
const defaultName = room.getDefaultRoomName(myId); const defaultName = room.getDefaultRoomName(myId);
this._initialName = name ? name.getContent().name : ''; this.setState({
name: name ? name.getContent().name : '',
});
this._placeholderName = _t("Unnamed Room"); 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. 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() { getRoomName: function() {
return this.refs.editor.getValue(); return this.state.name;
},
_onValueChanged: function(value, shouldSubmit) {
this.setState({
name: value,
});
}, },
render: function() { render: function() {
@ -57,7 +71,8 @@ module.exports = React.createClass({
placeholderClassName="mx_RoomHeader_placeholder" placeholderClassName="mx_RoomHeader_placeholder"
placeholder={this._placeholderName} placeholder={this._placeholderName}
blurToCancel={false} blurToCancel={false}
initialValue={this._initialName} initialValue={this.state.name}
onValueChanged={this._onValueChanged}
dir="auto" /> dir="auto" />
</div> </div>
); );

View file

@ -28,26 +28,41 @@ module.exports = React.createClass({
room: PropTypes.object.isRequired, room: PropTypes.object.isRequired,
}, },
getInitialState: function() {
return {
topic: null,
};
},
componentWillMount: function() { componentWillMount: function() {
const room = this.props.room; const room = this.props.room;
const topic = room.currentState.getStateEvents('m.room.topic', ''); const topic = room.currentState.getStateEvents('m.room.topic', '');
this._initialTopic = topic ? topic.getContent().topic : ''; this.setState({
topic: topic ? topic.getContent().topic : '',
});
}, },
getTopic: function() { getTopic: function() {
return this.refs.editor.getValue(); return this.state.topic;
},
_onValueChanged: function(value) {
this.setState({
topic: value,
});
}, },
render: function() { render: function() {
const EditableText = sdk.getComponent("elements.EditableText"); const EditableText = sdk.getComponent("elements.EditableText");
return ( return (
<EditableText ref="editor" <EditableText
className="mx_RoomHeader_topic mx_RoomHeader_editable" className="mx_RoomHeader_topic mx_RoomHeader_editable"
placeholderClassName="mx_RoomHeader_placeholder" placeholderClassName="mx_RoomHeader_placeholder"
placeholder={_t("Add a topic")} placeholder={_t("Add a topic")}
blurToCancel={false} blurToCancel={false}
initialValue={this._initialTopic} initialValue={this.state.topic}
onValueChanged={this._onValueChanged}
dir="auto" /> dir="auto" />
); );
}, },

251
src/stores/RoomListStore.js Normal file
View file

@ -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;

View file

@ -55,6 +55,7 @@ class TagOrderStore extends Store {
const tagOrderingEventContent = tagOrderingEvent ? tagOrderingEvent.getContent() : {}; const tagOrderingEventContent = tagOrderingEvent ? tagOrderingEvent.getContent() : {};
this._setState({ this._setState({
orderedTagsAccountData: tagOrderingEventContent.tags || null, orderedTagsAccountData: tagOrderingEventContent.tags || null,
removedTagsAccountData: tagOrderingEventContent.removedTags || null,
hasSynced: true, hasSynced: true,
}); });
this._updateOrderedTags(); this._updateOrderedTags();
@ -70,6 +71,7 @@ class TagOrderStore extends Store {
this._setState({ this._setState({
orderedTagsAccountData: payload.event_content ? payload.event_content.tags : null, orderedTagsAccountData: payload.event_content ? payload.event_content.tags : null,
removedTagsAccountData: payload.event_content ? payload.event_content.removedTags : null,
}); });
this._updateOrderedTags(); this._updateOrderedTags();
break; break;
@ -87,9 +89,18 @@ class TagOrderStore extends Store {
// Optimistic update of a moved tag // Optimistic update of a moved tag
this._setState({ this._setState({
orderedTags: payload.request.tags, orderedTags: payload.request.tags,
removedTagsAccountData: payload.request.removedTags,
}); });
break; break;
} }
case 'TagOrderActions.removeTag.pending': {
// Optimistic update of a removed tag
this._setState({
removedTagsAccountData: payload.request.removedTags,
});
this._updateOrderedTags();
break;
}
case 'select_tag': { case 'select_tag': {
let newTags = []; let newTags = [];
// Shift-click semantics // Shift-click semantics
@ -165,13 +176,15 @@ class TagOrderStore extends Store {
_mergeGroupsAndTags() { _mergeGroupsAndTags() {
const groupIds = this._state.joinedGroupIds || []; const groupIds = this._state.joinedGroupIds || [];
const tags = this._state.orderedTagsAccountData || []; const tags = this._state.orderedTagsAccountData || [];
const removedTags = new Set(this._state.removedTagsAccountData || []);
const tagsToKeep = tags.filter( const tagsToKeep = tags.filter(
(t) => t[0] !== '+' || groupIds.includes(t), (t) => (t[0] !== '+' || groupIds.includes(t)) && !removedTags.has(t),
); );
const groupIdsToAdd = groupIds.filter( const groupIdsToAdd = groupIds.filter(
(groupId) => !tags.includes(groupId), (groupId) => !tags.includes(groupId) && !removedTags.has(groupId),
); );
return tagsToKeep.concat(groupIdsToAdd); return tagsToKeep.concat(groupIdsToAdd);
@ -181,6 +194,10 @@ class TagOrderStore extends Store {
return this._state.orderedTags; return this._state.orderedTags;
} }
getRemovedTagsAccountData() {
return this._state.removedTagsAccountData;
}
getStoreId() { getStoreId() {
// Generate a random ID to prevent this store from clobbering its // Generate a random ID to prevent this store from clobbering its
// state with redundant remote echos. // state with redundant remote echos.