Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix_hide_joins_parts

This commit is contained in:
Michael Telatynski 2018-02-22 16:21:35 +00:00
commit 4c4c9506ca
No known key found for this signature in database
GPG key ID: 3F879DA5AD802A5E
34 changed files with 1150 additions and 415 deletions

View file

@ -1,3 +1,9 @@
Changes in [0.11.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.4) (2018-02-09)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.3...v0.11.4)
* Add isUrlPermitted function to sanity check URLs
Changes in [0.11.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.3) (2017-12-04) Changes in [0.11.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.3) (2017-12-04)
===================================================================================================== =====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.2...v0.11.3) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.2...v0.11.3)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "0.11.3", "version": "0.11.4",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {

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

@ -1,6 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -25,6 +25,7 @@ import escape from 'lodash/escape';
import emojione from 'emojione'; import emojione from 'emojione';
import classNames from 'classnames'; import classNames from 'classnames';
import MatrixClientPeg from './MatrixClientPeg'; import MatrixClientPeg from './MatrixClientPeg';
import url from 'url';
emojione.imagePathSVG = 'emojione/svg/'; emojione.imagePathSVG = 'emojione/svg/';
// Store PNG path for displaying many flags at once (for increased performance over SVG) // Store PNG path for displaying many flags at once (for increased performance over SVG)
@ -44,6 +45,8 @@ const SYMBOL_PATTERN = /([\u2100-\u2bff])/;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi");
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
/* /*
* Return true if the given string contains emoji * Return true if the given string contains emoji
* Uses a much, much simpler regex than emojione's so will give false * Uses a much, much simpler regex than emojione's so will give false
@ -152,6 +155,25 @@ export function sanitizedHtmlNode(insaneHtml) {
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />; return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
} }
/**
* Tests if a URL from an untrusted source may be safely put into the DOM
* The biggest threat here is javascript: URIs.
* Note that the HTML sanitiser library has its own internal logic for
* doing this, to which we pass the same list of schemes. This is used in
* other places we need to sanitise URLs.
* @return true if permitted, otherwise false
*/
export function isUrlPermitted(inputUrl) {
try {
const parsed = url.parse(inputUrl);
if (!parsed.protocol) return false;
// URL parser protocol includes the trailing colon
return PERMITTED_URL_SCHEMES.includes(parsed.protocol.slice(0, -1));
} catch (e) {
return false;
}
}
const sanitizeHtmlParams = { const sanitizeHtmlParams = {
allowedTags: [ allowedTags: [
'font', // custom to matrix for IRC-style font coloring 'font', // custom to matrix for IRC-style font coloring
@ -172,7 +194,7 @@ const sanitizeHtmlParams = {
// Lots of these won't come up by default because we don't allow them // Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
// URL schemes we permit // URL schemes we permit
allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'magnet'], allowedSchemes: PERMITTED_URL_SCHEMES,
allowProtocolRelative: false, allowProtocolRelative: false,

View file

@ -563,7 +563,7 @@ const onMessage = function(event) {
const url = SdkConfig.get().integrations_ui_url; const url = SdkConfig.get().integrations_ui_url;
if ( if (
event.origin.length === 0 || event.origin.length === 0 ||
!url.startsWith(event.origin) || !url.startsWith(event.origin + '/') ||
!event.data.action || !event.data.action ||
event.data.api // Ignore messages with specific API set event.data.api // Ignore messages with specific API set
) { ) {

View file

@ -28,6 +28,8 @@ module.exports = {
return false; return false;
} else if (ev.getType() == 'm.room.member') { } else if (ev.getType() == 'm.room.member') {
return false; return false;
} else if (ev.getType() == 'm.room.third_party_invite') {
return false;
} else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') { } else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') {
return false; return false;
} else if (ev.getType == 'm.room.message' && ev.getContent().msgtype == 'm.notify') { } else if (ev.getType == 'm.room.message' && ev.getContent().msgtype == 'm.notify') {

View file

@ -62,6 +62,107 @@ function createAccountDataAction(matrixClient, accountDataEvent) {
}; };
} }
/**
* @typedef RoomAction
* @type {Object}
* @property {string} action 'MatrixActions.Room'.
* @property {Room} room the Room that was stored.
*/
/**
* Create a MatrixActions.Room action that represents a MatrixClient `Room`
* matrix event, emitted when a Room is stored in the client.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {Room} room the Room that was stored.
* @returns {RoomAction} an action of type `MatrixActions.Room`.
*/
function createRoomAction(matrixClient, room) {
return { action: 'MatrixActions.Room', room };
}
/**
* @typedef RoomTagsAction
* @type {Object}
* @property {string} action 'MatrixActions.Room.tags'.
* @property {Room} room the Room whose tags changed.
*/
/**
* Create a MatrixActions.Room.tags action that represents a MatrixClient
* `Room.tags` matrix event, emitted when the m.tag room account data
* event is updated.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} roomTagsEvent the m.tag event.
* @param {Room} room the Room whose tags were changed.
* @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`.
*/
function createRoomTagsAction(matrixClient, roomTagsEvent, room) {
return { action: 'MatrixActions.Room.tags', room };
}
/**
* @typedef RoomTimelineAction
* @type {Object}
* @property {string} action 'MatrixActions.Room.timeline'.
* @property {boolean} isLiveEvent whether the event was attached to a
* live timeline.
* @property {boolean} isLiveUnfilteredRoomTimelineEvent whether the
* event was attached to a timeline in the set of unfiltered timelines.
* @property {Room} room the Room whose tags changed.
*/
/**
* Create a MatrixActions.Room.timeline action that represents a
* MatrixClient `Room.timeline` matrix event, emitted when an event
* is added to or removed from a timeline of a room.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} timelineEvent the event that was added/removed.
* @param {Room} room the Room that was stored.
* @param {boolean} toStartOfTimeline whether the event is being added
* to the start (and not the end) of the timeline.
* @param {boolean} removed whether the event was removed from the
* timeline.
* @param {Object} data
* @param {boolean} data.liveEvent whether the event is a live event,
* belonging to a live timeline.
* @param {EventTimeline} data.timeline the timeline being altered.
* @returns {RoomTimelineAction} an action of type `MatrixActions.Room.timeline`.
*/
function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTimeline, removed, data) {
return {
action: 'MatrixActions.Room.timeline',
event: timelineEvent,
isLiveEvent: data.liveEvent,
isLiveUnfilteredRoomTimelineEvent:
room && data.timeline.getTimelineSet() === room.getUnfilteredTimelineSet(),
};
}
/**
* @typedef RoomMembershipAction
* @type {Object}
* @property {string} action 'MatrixActions.RoomMember.membership'.
* @property {RoomMember} member the member whose membership was updated.
*/
/**
* Create a MatrixActions.RoomMember.membership action that represents
* a MatrixClient `RoomMember.membership` matrix event, emitted when a
* member's membership is updated.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} membershipEvent the m.room.member event.
* @param {RoomMember} member the member whose membership was updated.
* @param {string} oldMembership the member's previous membership.
* @returns {RoomMembershipAction} an action of type `MatrixActions.RoomMember.membership`.
*/
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 +179,10 @@ 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', createRoomAction);
this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction);
this._addMatrixClientListener(matrixClient, 'RoomMember.membership', createRoomMembershipAction);
}, },
/** /**
@ -91,7 +196,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,8 +376,8 @@ const LoggedInView = React.createClass({
return ( return (
<div className='mx_MatrixChat_wrapper'> <div className='mx_MatrixChat_wrapper'>
{ topBar } { topBar }
<DragDropContext onDragEnd={this._onDragEnd}>
<div className={bodyClasses}> <div className={bodyClasses}>
{ SettingsStore.isFeatureEnabled("feature_tag_panel") ? <TagPanel /> : <div /> }
<LeftPanel <LeftPanel
collapsed={this.props.collapseLhs || false} collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled} disabled={this.props.leftDisabled}
@ -340,6 +387,7 @@ const LoggedInView = React.createClass({
</main> </main>
{ right_panel } { right_panel }
</div> </div>
</DragDropContext>
</div> </div>
); );
}, },

View file

@ -618,18 +618,26 @@ export default React.createClass({
}, },
_startRegistration: function(params) { _startRegistration: function(params) {
this.setStateForNewView({ const newState = {
view: VIEWS.REGISTER, view: VIEWS.REGISTER,
// these params may be undefined, but if they are, };
// unset them from our state: we don't want to
// resume a previous registration session if the // Only honour params if they are all present, otherwise we reset
// user just clicked 'register' // HS and IS URLs when switching to registration.
register_client_secret: params.client_secret, if (params.client_secret &&
register_session_id: params.session_id, params.session_id &&
register_hs_url: params.hs_url, params.hs_url &&
register_is_url: params.is_url, params.is_url &&
register_id_sid: params.sid, params.sid
}); ) {
newState.register_client_secret = params.client_secret;
newState.register_session_id = params.session_id;
newState.register_hs_url = params.hs_url;
newState.register_is_url = params.is_url;
newState.register_id_sid = params.sid;
}
this.setStateForNewView(newState);
this.notifyNewScreen('register'); this.notifyNewScreen('register');
}, },
@ -1501,6 +1509,17 @@ export default React.createClass({
} }
}, },
onServerConfigChange(config) {
const newState = {};
if (config.hsUrl) {
newState.register_hs_url = config.hsUrl;
}
if (config.isUrl) {
newState.register_is_url = config.isUrl;
}
this.setState(newState);
},
_makeRegistrationUrl: function(params) { _makeRegistrationUrl: function(params) {
if (this.props.startingFragmentQueryParams.referrer) { if (this.props.startingFragmentQueryParams.referrer) {
params.referrer = this.props.startingFragmentQueryParams.referrer; params.referrer = this.props.startingFragmentQueryParams.referrer;
@ -1589,6 +1608,7 @@ export default React.createClass({
onLoginClick={this.onLoginClick} onLoginClick={this.onLoginClick}
onRegisterClick={this.onRegisterClick} onRegisterClick={this.onRegisterClick}
onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null} onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null}
onServerConfigChange={this.onServerConfigChange}
/> />
); );
} }
@ -1623,6 +1643,7 @@ export default React.createClass({
onForgotPasswordClick={this.onForgotPasswordClick} onForgotPasswordClick={this.onForgotPasswordClick}
enableGuest={this.props.enableGuest} enableGuest={this.props.enableGuest}
onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null} onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null}
onServerConfigChange={this.onServerConfigChange}
/> />
); );
} }

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>
<div className="mx_MyGroups_joinedGroups">
{ groupNodes } { groupNodes }
</div>
</GeminiScrollbar> : </GeminiScrollbar> :
<div className="mx_MyGroups_placeholder"> <div className="mx_MyGroups_placeholder">
{ _t( { _t(

View file

@ -264,12 +264,19 @@ module.exports = React.createClass({
isPeeking: true, // this will change to false if peeking fails isPeeking: true, // this will change to false if peeking fails
}); });
MatrixClientPeg.get().peekInRoom(roomId).then((room) => { MatrixClientPeg.get().peekInRoom(roomId).then((room) => {
if (this.unmounted) {
return;
}
this.setState({ this.setState({
room: room, room: room,
peekLoading: false, peekLoading: false,
}); });
this._onRoomLoaded(room); this._onRoomLoaded(room);
}, (err) => { }, (err) => {
if (this.unmounted) {
return;
}
// Stop peeking if anything went wrong // Stop peeking if anything went wrong
this.setState({ this.setState({
isPeeking: false, isPeeking: false,
@ -286,7 +293,7 @@ module.exports = React.createClass({
} else { } else {
throw err; throw err;
} }
}).done(); });
} }
} else if (room) { } else if (room) {
// Stop peeking because we have joined this room previously // Stop peeking because we have joined this room previously
@ -620,8 +627,8 @@ module.exports = React.createClass({
const room = this.state.room; const room = this.state.room;
if (!room) return; if (!room) return;
const color_scheme = SettingsStore.getValue("roomColor", room.room_id);
console.log("Tinter.tint from updateTint"); console.log("Tinter.tint from updateTint");
const color_scheme = SettingsStore.getValue("roomColor", room.roomId);
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
}, },
@ -670,23 +677,7 @@ module.exports = React.createClass({
// a member state changed in this room // a member state changed in this room
// refresh the conf call notification state // refresh the conf call notification state
this._updateConfCallNotification(); this._updateConfCallNotification();
this._updateDMState();
// if we are now a member of the room, where we were not before, that
// means we have finished joining a room we were previously peeking
// into.
const me = MatrixClientPeg.get().credentials.userId;
if (this.state.joining && this.state.room.hasMembershipState(me, "join")) {
// Having just joined a room, check to see if it looks like a DM room, and if so,
// mark it as one. This is to work around the fact that some clients don't support
// is_direct. We should remove this once they do.
const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId);
if (Rooms.looksLikeDirectMessageRoom(this.state.room, me)) {
// XXX: There's not a whole lot we can really do if this fails: at best
// perhaps we could try a couple more times, but since it's a temporary
// compatability workaround, let's not bother.
Rooms.setDMRoom(this.state.room.roomId, me.events.member.getSender()).done();
}
}
}, 500), }, 500),
_checkIfAlone: function(room) { _checkIfAlone: function(room) {
@ -727,6 +718,44 @@ module.exports = React.createClass({
}); });
}, },
_updateDMState() {
const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId);
if (!me || me.membership !== "join") {
return;
}
// The user may have accepted an invite with is_direct set
if (me.events.member.getPrevContent().membership === "invite" &&
me.events.member.getPrevContent().is_direct
) {
// This is a DM with the sender of the invite event (which we assume
// preceded the join event)
Rooms.setDMRoom(
this.state.room.roomId,
me.events.member.getUnsigned().prev_sender,
);
return;
}
const invitedMembers = this.state.room.getMembersWithMembership("invite");
const joinedMembers = this.state.room.getMembersWithMembership("join");
// There must be one invited member and one joined member
if (invitedMembers.length !== 1 || joinedMembers.length !== 1) {
return;
}
// The user may have sent an invite with is_direct sent
const other = invitedMembers[0];
if (other &&
other.membership === "invite" &&
other.events.member.getContent().is_direct
) {
Rooms.setDMRoom(this.state.room.roomId, other.userId);
return;
}
},
onSearchResultsResize: function() { onSearchResultsResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true); dis.dispatch({ action: 'timeline_resize' }, true);
}, },
@ -819,18 +848,6 @@ module.exports = React.createClass({
action: 'join_room', action: 'join_room',
opts: { inviteSignUrl: signUrl }, opts: { inviteSignUrl: signUrl },
}); });
// if this is an invite and has the 'direct' hint set, mark it as a DM room now.
if (this.state.room) {
const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId);
if (me && me.membership == 'invite') {
if (me.events.member.getContent().is_direct) {
// The 'direct' hint is there, so declare that this is a DM room for
// whoever invited us.
return Rooms.setDMRoom(this.state.room.roomId, me.events.member.getSender());
}
}
}
return Promise.resolve(); return Promise.resolve();
}); });
}, },

View file

@ -17,15 +17,16 @@ 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 GeminiScrollbar from 'react-gemini-scrollbar';
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 { _t } from '../../languageHandler';
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',
@ -84,8 +85,6 @@ const TagPanel = React.createClass({
}, },
onClick(e) { onClick(e) {
// Ignore clicks on children
if (e.target !== e.currentTarget) return;
dis.dispatch({action: 'deselect_tags'}); dis.dispatch({action: 'deselect_tags'});
}, },
@ -94,26 +93,14 @@ const TagPanel = React.createClass({
dis.dispatch({action: 'view_create_group'}); dis.dispatch({action: 'view_create_group'});
}, },
onTagTileEndDrag(result) { onClearFilterClick(ev) {
// Dragged to an invalid destination, not onto a droppable dis.dispatch({action: 'deselect_tags'});
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 AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const tags = this.state.orderedTags.map((tag, index) => { const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile return <DNDTagTile
@ -123,28 +110,45 @@ const TagPanel = React.createClass({
selected={this.state.selectedTags.includes(tag)} selected={this.state.selectedTags.includes(tag)}
/>; />;
}); });
const clearButton = this.state.selectedTags.length > 0 ?
<img
src="img/icons-close.svg"
alt={_t("Clear filter")}
title={_t("Clear filter")}
width="24"
height="24" /> :
<div />;
return <div className="mx_TagPanel"> return <div className="mx_TagPanel">
<DragDropContext onDragEnd={this.onTagTileEndDrag}> <AccessibleButton className="mx_TagPanel_clearButton" onClick={this.onClearFilterClick}>
<Droppable droppableId="tag-panel-droppable"> { clearButton }
</AccessibleButton>
<div className="mx_TagPanel_divider" />
<GeminiScrollbar
className="mx_TagPanel_scroller"
autoShow={true}
onClick={this.onClick}
>
<Droppable
droppableId="tag-panel-droppable"
type="draggable-TagTile"
>
{ (provided, snapshot) => ( { (provided, snapshot) => (
<div <div
className="mx_TagPanel_tagTileContainer" className="mx_TagPanel_tagTileContainer"
ref={provided.innerRef} ref={provided.innerRef}
// react-beautiful-dnd has a bug that emits a click to the parent
// of draggables upon dropping
// https://github.com/atlassian/react-beautiful-dnd/issues/273
// so we use onMouseDown here as a workaround.
onMouseDown={this.onClick}
> >
{ tags } { tags }
{ provided.placeholder } { provided.placeholder }
</div> </div>
) } ) }
</Droppable> </Droppable>
</DragDropContext> </GeminiScrollbar>
<AccessibleButton className="mx_TagPanel_createGroupButton" onClick={this.onCreateGroupClick}> <div className="mx_TagPanel_divider" />
<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,7 +1121,7 @@ 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

@ -58,6 +58,7 @@ module.exports = React.createClass({
// login shouldn't care how password recovery is done. // login shouldn't care how password recovery is done.
onForgotPasswordClick: PropTypes.func, onForgotPasswordClick: PropTypes.func,
onCancelClick: PropTypes.func, onCancelClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {
@ -218,6 +219,8 @@ module.exports = React.createClass({
if (config.isUrl !== undefined) { if (config.isUrl !== undefined) {
newState.enteredIdentityServerUrl = config.isUrl; newState.enteredIdentityServerUrl = config.isUrl;
} }
this.props.onServerConfigChange(config);
this.setState(newState, function() { this.setState(newState, function() {
self._initLoginLogic(config.hsUrl || null, config.isUrl); self._initLoginLogic(config.hsUrl || null, config.isUrl);
}); });
@ -428,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

@ -61,6 +61,7 @@ module.exports = React.createClass({
// registration shouldn't know or care how login is done. // registration shouldn't know or care how login is done.
onLoginClick: PropTypes.func.isRequired, onLoginClick: PropTypes.func.isRequired,
onCancelClick: PropTypes.func, onCancelClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {
@ -131,6 +132,7 @@ module.exports = React.createClass({
if (config.isUrl !== undefined) { if (config.isUrl !== undefined) {
newState.isUrl = config.isUrl; newState.isUrl = config.isUrl;
} }
this.props.onServerConfigChange(config);
this.setState(newState, function() { this.setState(newState, function() {
this._replaceClient(); this._replaceClient();
}); });

View file

@ -17,9 +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 { KeyCode } from '../../../Keyboard'; import { KeyCode } from '../../../Keyboard';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
/** /**
* Basic container for modal dialogs. * Basic container for modal dialogs.
@ -51,6 +54,20 @@ export default React.createClass({
children: PropTypes.node, children: PropTypes.node,
}, },
childContextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
getChildContext: function() {
return {
matrixClient: this._matrixClient,
};
},
componentWillMount() {
this._matrixClient = MatrixClientPeg.get();
},
_onKeyDown: function(e) { _onKeyDown: function(e) {
if (this.props.onKeyDown) { if (this.props.onKeyDown) {
this.props.onKeyDown(e); this.props.onKeyDown(e);

View file

@ -393,6 +393,10 @@ export default React.createClass({
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+ const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
"allow-same-origin allow-scripts allow-presentation"; "allow-same-origin allow-scripts allow-presentation";
// Additional iframe feature pemissions
// (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/)
const iframeFeatures = "microphone; camera; encrypted-media;";
if (this.props.show) { if (this.props.show) {
const loadingElement = ( const loadingElement = (
<div className='mx_AppTileBody mx_AppLoading'> <div className='mx_AppTileBody mx_AppLoading'>
@ -412,7 +416,13 @@ export default React.createClass({
appTileBody = ( appTileBody = (
<div className={this.state.loading ? 'mx_AppTileBody mx_AppLoading' : 'mx_AppTileBody'}> <div className={this.state.loading ? 'mx_AppTileBody mx_AppLoading' : 'mx_AppTileBody'}>
{ this.state.loading && loadingElement } { this.state.loading && loadingElement }
{ /*
The "is" attribute in the following iframe tag is needed in order to enable rendering of the
"allow" attribute, which is unknown to react 15.
*/ }
<iframe <iframe
is
allow={iframeFeatures}
ref="appFrame" ref="appFrame"
src={this._getSafeUrl()} src={this._getSafeUrl()}
allowFullScreen="true" allowFullScreen="true"

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

@ -132,7 +132,9 @@ module.exports = React.createClass({
render: function() { render: function() {
if (this.state.removingUser) { if (this.state.removingUser) {
const Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />; return <div className="mx_MemberInfo">
<Spinner />
</div>;
} }
let adminTools; let adminTools;

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}>
<Droppable droppableId="my-groups-droppable" type="draggable-TagTile">
{ (droppableProvided, droppableSnapshot) => (
<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"> <div className="mx_GroupTile_avatar">
<BaseAvatar name={name} url={httpUrl} width={avatarHeight} height={avatarHeight} /> <BaseAvatar name={name} url={httpUrl} width={avatarHeight} height={avatarHeight} />
</div> </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,15 +26,15 @@ 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;
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
function phraseForSection(section) { function phraseForSection(section) {
switch (section) { switch (section) {
@ -78,9 +77,7 @@ module.exports = React.createClass({
cli.on("Room", this.onRoom); cli.on("Room", this.onRoom);
cli.on("deleteRoom", this.onDeleteRoom); cli.on("deleteRoom", this.onDeleteRoom);
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
@ -176,9 +177,7 @@ module.exports = React.createClass({
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room", this.onRoom); MatrixClientPeg.get().removeListener("Room", this.onRoom);
MatrixClientPeg.get().removeListener("deleteRoom", this.onDeleteRoom); MatrixClientPeg.get().removeListener("deleteRoom", this.onDeleteRoom);
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 +190,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());
@ -232,13 +235,6 @@ module.exports = React.createClass({
this._updateStickyHeaders(true, scrollToPosition); this._updateStickyHeaders(true, scrollToPosition);
}, },
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
if (toStartOfTimeline) return;
if (!room) return;
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
this._delayedRefreshRoomList();
},
onRoomReceipt: function(receiptEvent, room) { onRoomReceipt: function(receiptEvent, room) {
// because if we read a notification, it will affect notification count // because if we read a notification, it will affect notification count
// only bother updating if there's a receipt from us // only bother updating if there's a receipt from us
@ -251,10 +247,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 +270,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 +333,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 +344,38 @@ 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") { Object.keys(lists).forEach((tagName) => {
lists["im.vector.fake.invite"].push(room); const filteredRooms = lists[tagName].filter((taggedRoom) => {
} else if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, me, this.props.ConferenceHandler)) { // Somewhat impossible, but guard against it anyway
// skip past this room & don't put it in any lists if (!taggedRoom) {
} else if (me.membership == "join" || me.membership === "ban" || return;
(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)) { const me = taggedRoom.getMember(MatrixClientPeg.get().credentials.userId);
// "Direct Message" rooms (that we're still in and that aren't otherwise tagged) if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(taggedRoom, me, this.props.ConferenceHandler)) {
lists["im.vector.fake.direct"].push(room); return;
} else {
lists["im.vector.fake.recent"].push(room);
} }
} else if (me.membership === "leave") {
lists["im.vector.fake.archived"].push(room); return Boolean(isRoomVisible[taggedRoom.roomId]);
} else { });
console.error("unrecognised membership: " + me.membership + " - this should never happen");
if (filteredRooms.length > 0 || tagName.match(STANDARD_TAGS_REGEX)) {
filteredLists[tagName] = filteredRooms;
} }
}); });
// we actually apply the sorting to this when receiving the prop in RoomSubLists. return filteredLists;
// 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 lists;
}, },
_getScrollNode: function() { _getScrollNode: function() {
@ -752,7 +612,6 @@ 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">
@ -819,7 +678,7 @@ module.exports = React.createClass({
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(STANDARD_TAGS_REGEX)) {
return <RoomSubList list={self.state.lists[tagName]} return <RoomSubList list={self.state.lists[tagName]}
key={tagName} key={tagName}
label={tagName} label={tagName}
@ -861,7 +720,6 @@ module.exports = React.createClass({
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" />
); );
}, },

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -18,6 +19,7 @@ import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import sdk from '../../../index';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'IncomingCallBox', displayName: 'IncomingCallBox',
@ -26,14 +28,16 @@ module.exports = React.createClass({
incomingCall: PropTypes.object, incomingCall: PropTypes.object,
}, },
onAnswerClick: function() { onAnswerClick: function(e) {
e.stopPropagation();
dis.dispatch({ dis.dispatch({
action: 'answer', action: 'answer',
room_id: this.props.incomingCall.roomId, room_id: this.props.incomingCall.roomId,
}); });
}, },
onRejectClick: function() { onRejectClick: function(e) {
e.stopPropagation();
dis.dispatch({ dis.dispatch({
action: 'hangup', action: 'hangup',
room_id: this.props.incomingCall.roomId, room_id: this.props.incomingCall.roomId,
@ -59,6 +63,7 @@ module.exports = React.createClass({
} }
} }
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return ( return (
<div className="mx_IncomingCallBox" id="incomingCallBox"> <div className="mx_IncomingCallBox" id="incomingCallBox">
<img className="mx_IncomingCallBox_chevron" src="img/chevron-left.png" width="9" height="16" /> <img className="mx_IncomingCallBox_chevron" src="img/chevron-left.png" width="9" height="16" />
@ -67,14 +72,14 @@ module.exports = React.createClass({
</div> </div>
<div className="mx_IncomingCallBox_buttons"> <div className="mx_IncomingCallBox_buttons">
<div className="mx_IncomingCallBox_buttons_cell"> <div className="mx_IncomingCallBox_buttons_cell">
<div className="mx_IncomingCallBox_buttons_decline" onClick={this.onRejectClick}> <AccessibleButton className="mx_IncomingCallBox_buttons_decline" onClick={this.onRejectClick}>
{ _t("Decline") } { _t("Decline") }
</div> </AccessibleButton>
</div> </div>
<div className="mx_IncomingCallBox_buttons_cell"> <div className="mx_IncomingCallBox_buttons_cell">
<div className="mx_IncomingCallBox_buttons_accept" onClick={this.onAnswerClick}> <AccessibleButton className="mx_IncomingCallBox_buttons_accept" onClick={this.onAnswerClick}>
{ _t("Accept") } { _t("Accept") }
</div> </AccessibleButton>
</div> </div>
</div> </div>
</div> </div>

View file

@ -381,9 +381,6 @@
"Drop here to restore": "Drop here to restore", "Drop here to restore": "Drop here to restore",
"Drop here to demote": "Drop here to demote", "Drop here to demote": "Drop here to demote",
"Drop here to tag %(section)s": "Drop here to tag %(section)s", "Drop here to tag %(section)s": "Drop here to tag %(section)s",
"Failed to set direct chat tag": "Failed to set direct chat tag",
"Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room",
"Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room",
"Press <StartChatButton> to start a chat with someone": "Press <StartChatButton> to start a chat with someone", "Press <StartChatButton> to start a chat with someone": "Press <StartChatButton> to start a chat with someone",
"You're not in any rooms yet! Press <CreateRoomButton> to make a room or <RoomDirectoryButton> to browse the directory": "You're not in any rooms yet! Press <CreateRoomButton> to make a room or <RoomDirectoryButton> to browse the directory", "You're not in any rooms yet! Press <CreateRoomButton> to make a room or <RoomDirectoryButton> to browse the directory": "You're not in any rooms yet! Press <CreateRoomButton> to make a room or <RoomDirectoryButton> to browse the directory",
"Community Invites": "Community Invites", "Community Invites": "Community Invites",
@ -500,8 +497,8 @@
"Download %(text)s": "Download %(text)s", "Download %(text)s": "Download %(text)s",
"Invalid file%(extra)s": "Invalid file%(extra)s", "Invalid file%(extra)s": "Invalid file%(extra)s",
"Error decrypting image": "Error decrypting image", "Error decrypting image": "Error decrypting image",
"Image '%(Body)s' cannot be displayed.": "Image '%(Body)s' cannot be displayed.",
"This image cannot be displayed.": "This image cannot be displayed.", "This image cannot be displayed.": "This image cannot be displayed.",
"Image '%(Body)s' cannot be displayed.": "Image '%(Body)s' cannot be displayed.",
"Error decrypting video": "Error decrypting video", "Error decrypting video": "Error decrypting video",
"%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s", "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s",
"%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.", "%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.",
@ -826,6 +823,7 @@
"Click to mute video": "Click to mute video", "Click to mute video": "Click to mute video",
"Click to unmute audio": "Click to unmute audio", "Click to unmute audio": "Click to unmute audio",
"Click to mute audio": "Click to mute audio", "Click to mute audio": "Click to mute audio",
"Clear filter": "Clear filter",
"Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
"Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
"Failed to load timeline position": "Failed to load timeline position", "Failed to load timeline position": "Failed to load timeline position",
@ -984,5 +982,8 @@
"This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.": "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.", "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.": "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.",
"The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.": "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.", "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.": "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.",
"File to import": "File to import", "File to import": "File to import",
"Import": "Import" "Import": "Import",
"Failed to set direct chat tag": "Failed to set direct chat tag",
"Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room",
"Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room"
} }

View file

@ -35,13 +35,17 @@ module.exports = function(f, minIntervalMs) {
if (self.lastCall < now - minIntervalMs) { if (self.lastCall < now - minIntervalMs) {
f.apply(this); f.apply(this);
self.lastCall = now; // get the time again now the function has finished, so if it
// took longer than the delay time to execute, it doesn't
// immediately become eligible to run again.
self.lastCall = Date.now();
} else if (self.scheduledCall === undefined) { } else if (self.scheduledCall === undefined) {
self.scheduledCall = setTimeout( self.scheduledCall = setTimeout(
() => { () => {
self.scheduledCall = undefined; self.scheduledCall = undefined;
f.apply(this); f.apply(this);
self.lastCall = now; // get time again as per above
self.lastCall = Date.now();
}, },
(self.lastCall + minIntervalMs) - now, (self.lastCall + minIntervalMs) - now,
); );

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

@ -0,0 +1,275 @@
/*
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 {
static _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",
};
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.Room.timeline': {
if (!this._state.ready ||
!payload.isLiveEvent ||
!payload.isLiveUnfilteredRoomTimelineEvent ||
!this._eventTriggersRecentReorder(payload.event)
) 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;
// This could be a new room that we've been invited to, joined or created
// we won't get a RoomMember.membership for these cases if we're not already
// a member.
case 'MatrixActions.Room': {
if (!this._state.ready || !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");
}
});
Object.keys(lists).forEach((listKey) => {
let comparator;
switch (RoomListStore._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
});
}
_eventTriggersRecentReorder(ev) {
return ev.getTs() && (
Unread.eventTriggersUnreadCount(ev) ||
ev.getSender() === this._matrixClient.credentials.userId
);
}
_tsOfNewestEvent(room) {
for (let i = room.timeline.length - 1; i >= 0; --i) {
const ev = room.timeline[i];
if (this._eventTriggersRecentReorder(ev)) {
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) {
// XXX: We could use a cache here and update it when we see new
// events that trigger a reorder
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.