diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js
index cbe80763a6..6276a45839 100644
--- a/src/components/views/dialogs/AddressPickerDialog.js
+++ b/src/components/views/dialogs/AddressPickerDialog.js
@@ -389,6 +389,17 @@ module.exports = React.createClass({
const suggestedList = [];
results.forEach((result) => {
if (result.room_id) {
+ const client = MatrixClientPeg.get();
+ const room = client.getRoom(result.room_id);
+ if (room) {
+ const tombstone = room.currentState.getStateEvents('m.room.tombstone', '');
+ if (tombstone && tombstone.getContent() && tombstone.getContent()["replacement_room"]) {
+ const replacementRoom = client.getRoom(tombstone.getContent()["replacement_room"]);
+
+ // Skip rooms with tombstones where we are also aware of the replacement room.
+ if (replacementRoom) return;
+ }
+ }
suggestedList.push({
addressType: 'mx-room-id',
address: result.room_id,
diff --git a/src/components/views/dialogs/AskInviteAnywayDialog.js b/src/components/views/dialogs/AskInviteAnywayDialog.js
new file mode 100644
index 0000000000..5c61c3a694
--- /dev/null
+++ b/src/components/views/dialogs/AskInviteAnywayDialog.js
@@ -0,0 +1,81 @@
+/*
+Copyright 2019 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 React from 'react';
+import PropTypes from 'prop-types';
+import sdk from '../../../index';
+import { _t } from '../../../languageHandler';
+import {SettingLevel} from "../../../settings/SettingsStore";
+import SettingsStore from "../../../settings/SettingsStore";
+
+export default React.createClass({
+ propTypes: {
+ unknownProfileUsers: PropTypes.array.isRequired, // [ {userId, errorText}... ]
+ onInviteAnyways: PropTypes.func.isRequired,
+ onGiveUp: PropTypes.func.isRequired,
+ onFinished: PropTypes.func.isRequired,
+ },
+
+ _onInviteClicked: function() {
+ this.props.onInviteAnyways();
+ this.props.onFinished(true);
+ },
+
+ _onInviteNeverWarnClicked: function() {
+ SettingsStore.setValue("alwaysInviteUnknownUsers", null, SettingLevel.ACCOUNT, true);
+ this.props.onInviteAnyways();
+ this.props.onFinished(true);
+ },
+
+ _onGiveUpClicked: function() {
+ this.props.onGiveUp();
+ this.props.onFinished(false);
+ },
+
+ render: function() {
+ const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
+
+ const errorList = this.props.unknownProfileUsers
+ .map(address =>
+ );
+ },
+});
diff --git a/src/components/views/right_panel/HeaderButtons.js b/src/components/views/right_panel/HeaderButtons.js
index f0479eb8be..3f5f58121d 100644
--- a/src/components/views/right_panel/HeaderButtons.js
+++ b/src/components/views/right_panel/HeaderButtons.js
@@ -78,7 +78,6 @@ export default class HeaderButtons extends React.Component {
// till show_right_panel, just without the fromHeader flag
// as that would hide the right panel again
dis.dispatch(Object.assign({}, payload, {fromHeader: false}));
-
}
this.setState({
phase: payload.phase,
diff --git a/src/components/views/rooms/AuxPanel.js b/src/components/views/rooms/AuxPanel.js
index 64c0478d41..5370b4d8b5 100644
--- a/src/components/views/rooms/AuxPanel.js
+++ b/src/components/views/rooms/AuxPanel.js
@@ -24,6 +24,8 @@ import ObjectUtils from '../../../ObjectUtils';
import AppsDrawer from './AppsDrawer';
import { _t } from '../../../languageHandler';
import classNames from 'classnames';
+import RateLimitedFunc from '../../../ratelimitedfunc';
+import SettingsStore from "../../../settings/SettingsStore";
module.exports = React.createClass({
@@ -60,6 +62,22 @@ module.exports = React.createClass({
hideAppsDrawer: false,
},
+ getInitialState: function() {
+ return { counters: this._computeCounters() };
+ },
+
+ componentDidMount: function() {
+ const cli = MatrixClientPeg.get();
+ cli.on("RoomState.events", this._rateLimitedUpdate);
+ },
+
+ componentWillUnmount: function() {
+ const cli = MatrixClientPeg.get();
+ if (cli) {
+ cli.removeListener("RoomState.events", this._rateLimitedUpdate);
+ }
+ },
+
shouldComponentUpdate: function(nextProps, nextState) {
return (!ObjectUtils.shallowEqual(this.props, nextProps) ||
!ObjectUtils.shallowEqual(this.state, nextState));
@@ -82,6 +100,43 @@ module.exports = React.createClass({
ev.preventDefault();
},
+ _rateLimitedUpdate: new RateLimitedFunc(function() {
+ if (SettingsStore.isFeatureEnabled("feature_state_counters")) {
+ this.setState({counters: this._computeCounters()});
+ }
+ }, 500),
+
+ _computeCounters: function() {
+ let counters = [];
+
+ if (this.props.room && SettingsStore.isFeatureEnabled("feature_state_counters")) {
+ const stateEvs = this.props.room.currentState.getStateEvents('re.jki.counter');
+ stateEvs.sort((a, b) => {
+ return a.getStateKey() < b.getStateKey();
+ });
+
+ stateEvs.forEach((ev, idx) => {
+ const title = ev.getContent().title;
+ const value = ev.getContent().value;
+ const link = ev.getContent().link;
+ const severity = ev.getContent().severity || "normal";
+ const stateKey = ev.getStateKey();
+
+ if (title && value && severity) {
+ counters.push({
+ "title": title,
+ "value": value,
+ "link": link,
+ "severity": severity,
+ "stateKey": stateKey
+ })
+ }
+ });
+ }
+
+ return counters;
+ },
+
render: function() {
const CallView = sdk.getComponent("voip.CallView");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
@@ -145,6 +200,58 @@ module.exports = React.createClass({
hide={this.props.hideAppsDrawer}
/>;
+ let stateViews = null;
+ if (this.state.counters && SettingsStore.isFeatureEnabled("feature_state_counters")) {
+ let counters = [];
+
+ this.state.counters.forEach((counter, idx) => {
+ const title = counter.title;
+ const value = counter.value;
+ const link = counter.link;
+ const severity = counter.severity;
+ const stateKey = counter.stateKey;
+
+ if (title && value && severity) {
+ let span =
+
+ if (link) {
+ span = (
+
+ );
+ }
+
+ span = (
+
+ );
+
+ counters.push(span);
+ counters.push(
+
+ );
+ }
+ });
+
+ if (counters.length > 0) {
+ counters.pop(); // remove last deliminator
+ stateViews = (
+
+ );
+ }
+ }
+
const classes = classNames({
"mx_RoomView_auxPanel": true,
"mx_RoomView_auxPanel_fullHeight": this.props.fullHeight,
@@ -156,6 +263,7 @@ module.exports = React.createClass({
return (
+ { stateViews }
{ appsDrawer }
{ fileDropTarget }
{ callView }
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index e978bf438a..692111361a 100644
--- a/src/components/views/rooms/EventTile.js
+++ b/src/components/views/rooms/EventTile.js
@@ -62,6 +62,7 @@ const stateEventTileTypes = {
'm.room.pinned_events': 'messages.TextualEvent',
'm.room.server_acl': 'messages.TextualEvent',
'im.vector.modular.widgets': 'messages.TextualEvent',
+ 'm.room.tombstone': 'messages.TextualEvent',
};
function getHandlerTile(ev) {
diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js
index 12dc2117a0..2b50ff5e48 100644
--- a/src/components/views/rooms/MemberInfo.js
+++ b/src/components/views/rooms/MemberInfo.js
@@ -39,7 +39,6 @@ import Unread from '../../../Unread';
import { findReadReceiptFromUserId } from '../../../utils/Receipt';
import withMatrixClient from '../../../wrappers/withMatrixClient';
import AccessibleButton from '../elements/AccessibleButton';
-import RoomViewStore from '../../../stores/RoomViewStore';
import SdkConfig from '../../../SdkConfig';
import MultiInviter from "../../../utils/MultiInviter";
import SettingsStore from "../../../settings/SettingsStore";
@@ -50,6 +49,7 @@ module.exports = withMatrixClient(React.createClass({
propTypes: {
matrixClient: PropTypes.object.isRequired,
member: PropTypes.object.isRequired,
+ roomId: PropTypes.string,
},
getInitialState: function() {
@@ -713,8 +713,8 @@ module.exports = withMatrixClient(React.createClass({
}
if (!member || !member.membership || member.membership === 'leave') {
- const roomId = member && member.roomId ? member.roomId : RoomViewStore.getRoomId();
- const onInviteUserButton = async() => {
+ const roomId = member && member.roomId ? member.roomId : this.props.roomId;
+ const onInviteUserButton = async () => {
try {
// We use a MultiInviter to re-use the invite logic, even though
// we're only inviting one user.
diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js
index d4b607a93a..c648ca615b 100644
--- a/src/components/views/rooms/MessageComposer.js
+++ b/src/components/views/rooms/MessageComposer.js
@@ -22,7 +22,6 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import sdk from '../../../index';
import dis from '../../../dispatcher';
-import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import Stickerpicker from './Stickerpicker';
import { makeRoomPermalink } from '../../../matrix-to';
@@ -63,7 +62,7 @@ export default class MessageComposer extends React.Component {
isRichTextEnabled: SettingsStore.getValue('MessageComposerInput.isRichTextEnabled'),
},
showFormatting: SettingsStore.getValue('MessageComposer.showFormatting'),
- isQuoting: Boolean(RoomViewStore.getQuotingEvent()),
+ isQuoting: Boolean(this.props.roomViewStore.getQuotingEvent()),
tombstone: this._getRoomTombstone(),
};
}
@@ -75,7 +74,7 @@ export default class MessageComposer extends React.Component {
// XXX: fragile as all hell - fixme somehow, perhaps with a dedicated Room.encryption event or something.
MatrixClientPeg.get().on("event", this.onEvent);
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
- this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
+ this._roomStoreToken = this.props.roomViewStore.addListener(this._onRoomViewStoreUpdate);
this._waitForOwnMember();
}
@@ -124,14 +123,14 @@ export default class MessageComposer extends React.Component {
}
_onRoomViewStoreUpdate() {
- const isQuoting = Boolean(RoomViewStore.getQuotingEvent());
+ const isQuoting = Boolean(this.props.roomViewStore.getQuotingEvent());
if (this.state.isQuoting === isQuoting) return;
this.setState({ isQuoting });
}
onUploadClick(ev) {
if (MatrixClientPeg.get().isGuest()) {
- dis.dispatch({action: 'require_registration'});
+ this.props.roomViewStore.getDispatcher().dispatch({action: 'require_registration'});
return;
}
@@ -165,7 +164,7 @@ export default class MessageComposer extends React.Component {
}
}
- const isQuoting = Boolean(RoomViewStore.getQuotingEvent());
+ const isQuoting = Boolean(this.props.roomViewStore.getQuotingEvent());
let replyToWarning = null;
if (isQuoting) {
replyToWarning =
{
@@ -229,7 +228,7 @@ export default class MessageComposer extends React.Component {
if (!call) {
return;
}
- dis.dispatch({
+ this.props.roomViewStore.getDispatcher().dispatch({
action: 'hangup',
// hangup the call for this room, which may not be the room in props
// (e.g. conferences which will hangup the 1:1 room instead)
@@ -238,7 +237,7 @@ export default class MessageComposer extends React.Component {
}
onCallClick(ev) {
- dis.dispatch({
+ this.props.roomViewStore.getDispatcher().dispatch({
action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video",
room_id: this.props.room.roomId,
@@ -246,7 +245,7 @@ export default class MessageComposer extends React.Component {
}
onVoiceCallClick(ev) {
- dis.dispatch({
+ this.props.roomViewStore.getDispatcher().dispatch({
action: 'place_call',
type: "voice",
room_id: this.props.room.roomId,
@@ -282,10 +281,22 @@ export default class MessageComposer extends React.Component {
ev.preventDefault();
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
- dis.dispatch({
+ const replacementRoom = MatrixClientPeg.get().getRoom(replacementRoomId);
+ let createEventId = null;
+ if (replacementRoom) {
+ const createEvent = replacementRoom.currentState.getStateEvents('m.room.create', '');
+ if (createEvent && createEvent.getId()) createEventId = createEvent.getId();
+ }
+
+ this.props.roomViewStore.getDispatcher().dispatch({
action: 'view_room',
highlighted: true,
+ event_id: createEventId,
room_id: replacementRoomId,
+
+ // Try to join via the server that sent the event. This converts $something:example.org
+ // into a server domain by splitting on colons and ignoring the first entry ("$something").
+ via_servers: [this.state.tombstone.getId().split(':').splice(1).join(':')],
});
}
@@ -421,8 +432,10 @@ export default class MessageComposer extends React.Component {
controls.push(
this.messageComposerInput = c}
key="controls_input"
+ isGrid={this.props.isGrid}
onResize={this.props.onResize}
room={this.props.room}
placeholder={placeholderText}
@@ -529,5 +542,6 @@ MessageComposer.propTypes = {
uploadAllowed: PropTypes.func.isRequired,
// string representing the current room app drawer state
- showApps: PropTypes.bool
+ showApps: PropTypes.bool,
+ roomViewStore: PropTypes.object.isRequired,
};
diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js
index 14d394ab41..4c800ad8d2 100644
--- a/src/components/views/rooms/MessageComposerInput.js
+++ b/src/components/views/rooms/MessageComposerInput.js
@@ -15,13 +15,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
-import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
-import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent';
import { Editor } from 'slate-react';
import { getEventTransfer } from 'slate-react';
-import { Value, Document, Block, Inline, Text, Range, Node } from 'slate';
+import { Value, Block, Inline, Range } from 'slate';
import type { Change } from 'slate';
import Html from 'slate-html-serializer';
@@ -30,7 +28,6 @@ import Plain from 'slate-plain-serializer';
import PlainWithPillsSerializer from "../../../autocomplete/PlainWithPillsSerializer";
import classNames from 'classnames';
-import Promise from 'bluebird';
import MatrixClientPeg from '../../../MatrixClientPeg';
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
@@ -38,11 +35,9 @@ import {processCommandInput} from '../../../SlashCommands';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../../Keyboard';
import Modal from '../../../Modal';
import sdk from '../../../index';
-import { _t, _td } from '../../../languageHandler';
+import { _t } from '../../../languageHandler';
import Analytics from '../../../Analytics';
-import dis from '../../../dispatcher';
-
import * as RichText from '../../../RichText';
import * as HtmlUtils from '../../../HtmlUtils';
import Autocomplete from './Autocomplete';
@@ -51,28 +46,24 @@ import Markdown from '../../../Markdown';
import ComposerHistoryManager from '../../../ComposerHistoryManager';
import MessageComposerStore from '../../../stores/MessageComposerStore';
-import {MATRIXTO_MD_LINK_PATTERN, MATRIXTO_URL_PATTERN} from '../../../linkify-matrix';
-const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g');
+import {MATRIXTO_URL_PATTERN} from '../../../linkify-matrix';
-import {asciiRegexp, unicodeRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort, toShort} from 'emojione';
+import {
+ asciiRegexp, unicodeRegexp, shortnameToUnicode,
+ asciiList, mapUnicodeToShort, toShort,
+} from 'emojione';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import {makeUserPermalink} from "../../../matrix-to";
import ReplyPreview from "./ReplyPreview";
-import RoomViewStore from '../../../stores/RoomViewStore';
import ReplyThread from "../elements/ReplyThread";
import {ContentHelpers} from 'matrix-js-sdk';
-const EMOJI_SHORTNAMES = Object.keys(emojioneList);
const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort();
const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$');
const EMOJI_REGEX = new RegExp(unicodeRegexp, 'g');
const TYPING_USER_TIMEOUT = 10000; const TYPING_SERVER_TIMEOUT = 30000;
-const ENTITY_TYPES = {
- AT_ROOM_PILL: 'ATROOMPILL',
-};
-
// the Slate node type to default to for unstyled text
const DEFAULT_NODE = 'paragraph';
@@ -117,15 +108,6 @@ const SLATE_SCHEMA = {
},
};
-function onSendMessageFailed(err, room) {
- // XXX: temporary logging to try to diagnose
- // https://github.com/vector-im/riot-web/issues/3148
- console.log('MessageComposer got send failure: ' + err.name + '('+err+')');
- dis.dispatch({
- action: 'message_send_failed',
- });
-}
-
function rangeEquals(a: Range, b: Range): boolean {
return (a.anchor.key === b.anchor.key
&& a.anchor.offset === b.anchorOffset
@@ -135,6 +117,18 @@ function rangeEquals(a: Range, b: Range): boolean {
&& a.isBackward === b.isBackward);
}
+class NoopHistoryManager {
+ getItem() {}
+ save() {}
+
+ get currentIndex() { return 0; }
+ set currentIndex(_) {}
+
+ get history() { return []; }
+ set history(_) {}
+}
+
+
/*
* The textInput part of the MessageComposer
*/
@@ -150,6 +144,7 @@ export default class MessageComposerInput extends React.Component {
onFilesPasted: PropTypes.func,
onInputStateChanged: PropTypes.func,
+ roomViewStore: PropTypes.object.isRequired,
};
client: MatrixClient;
@@ -344,20 +339,32 @@ export default class MessageComposerInput extends React.Component {
}
componentWillMount() {
- this.dispatcherRef = dis.register(this.onAction);
- this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_');
+ this.dispatcherRef = this.props.roomViewStore.getDispatcher().register(this.onAction);
+ if (this.props.isGrid) {
+ this.historyManager = new NoopHistoryManager();
+ } else {
+ this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_');
+ }
}
componentWillUnmount() {
- dis.unregister(this.dispatcherRef);
+ this.props.roomViewStore.getDispatcher().unregister(this.dispatcherRef);
}
_collectEditor = (e) => {
this._editor = e;
}
+ onSendMessageFailed = (err, room) => {
+ // XXX: temporary logging to try to diagnose
+ // https://github.com/vector-im/riot-web/issues/3148
+ console.log('MessageComposer got send failure: ' + err.name + '('+err+')');
+ this.props.roomViewStore.getDispatcher().dispatch({
+ action: 'message_send_failed',
+ });
+ }
+
onAction = (payload) => {
- const editor = this._editor;
const editorState = this.state.editorState;
switch (payload.action) {
@@ -854,7 +861,7 @@ export default class MessageComposerInput extends React.Component {
return true;
}
- const newState: ?Value = null;
+ //const newState: ?Value = null;
// Draft handles rich text mode commands by default but we need to do it ourselves for Markdown.
if (this.state.isRichTextEnabled) {
@@ -1105,7 +1112,9 @@ export default class MessageComposerInput extends React.Component {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Server error', '', ErrorDialog, {
title: _t("Server error"),
- description: ((err && err.message) ? err.message : _t("Server unavailable, overloaded, or something else went wrong.")),
+ description: ((err && err.message) ? err.message : _t(
+ "Server unavailable, overloaded, or something else went wrong.",
+ )),
});
});
} else if (cmd.error) {
@@ -1120,7 +1129,7 @@ export default class MessageComposerInput extends React.Component {
return true;
}
- const replyingToEv = RoomViewStore.getQuotingEvent();
+ const replyingToEv = this.props.roomViewStore.getQuotingEvent();
const mustSendHTML = Boolean(replyingToEv);
if (this.state.isRichTextEnabled) {
@@ -1208,18 +1217,18 @@ export default class MessageComposerInput extends React.Component {
// Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending.
- dis.dispatch({
+ this.props.roomViewStore.getDispatcher().dispatch({
action: 'reply_to_event',
event: null,
});
}
this.client.sendMessage(this.props.room.roomId, content).then((res) => {
- dis.dispatch({
+ this.props.roomViewStore.getDispatcher().dispatch({
action: 'message_sent',
});
}).catch((e) => {
- onSendMessageFailed(e, this.props.room);
+ this.onSendMessageFailed(e, this.props.room);
});
this.setState({
@@ -1260,7 +1269,7 @@ export default class MessageComposerInput extends React.Component {
}
};
- selectHistory = async(up) => {
+ selectHistory = async (up) => {
const delta = up ? -1 : 1;
// True if we are not currently selecting history, but composing a message
@@ -1308,7 +1317,7 @@ export default class MessageComposerInput extends React.Component {
return true;
};
- onTab = async(e) => {
+ onTab = async (e) => {
this.setState({
someCompletions: null,
});
@@ -1330,7 +1339,7 @@ export default class MessageComposerInput extends React.Component {
up ? this.autocomplete.onUpArrow() : this.autocomplete.onDownArrow();
};
- onEscape = async(e) => {
+ onEscape = async (e) => {
e.preventDefault();
if (this.autocomplete) {
this.autocomplete.onEscape(e);
@@ -1349,7 +1358,7 @@ export default class MessageComposerInput extends React.Component {
/* If passed null, restores the original editor content from state.originalEditorState.
* If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState.
*/
- setDisplayedCompletion = async(displayedCompletion: ?Completion): boolean => {
+ setDisplayedCompletion = async (displayedCompletion: ?Completion): boolean => {
const activeEditorState = this.state.originalEditorState || this.state.editorState;
if (displayedCompletion == null) {
@@ -1484,7 +1493,9 @@ export default class MessageComposerInput extends React.Component {
});
const style = {};
if (props.selected) style.border = '1px solid blue';
- return ;
+ return ;
}
}
};
@@ -1538,7 +1549,6 @@ export default class MessageComposerInput extends React.Component {
getSelectionRange(editorState: Value) {
let beginning = false;
- const query = this.getAutocompleteQuery(editorState);
const firstChild = editorState.document.nodes.get(0);
const firstGrandChild = firstChild && firstChild.nodes.get(0);
beginning = (firstChild && firstGrandChild &&
@@ -1589,7 +1599,7 @@ export default class MessageComposerInput extends React.Component {
return (
-
+
this.autocomplete = e}
room={this.props.room}
diff --git a/src/components/views/rooms/ReplyPreview.js b/src/components/views/rooms/ReplyPreview.js
index 46e2826634..04ff9d0778 100644
--- a/src/components/views/rooms/ReplyPreview.js
+++ b/src/components/views/rooms/ReplyPreview.js
@@ -18,7 +18,6 @@ import React from 'react';
import dis from '../../../dispatcher';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
-import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore";
function cancelQuoting() {
@@ -38,7 +37,7 @@ export default class ReplyPreview extends React.Component {
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
- this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
+ this._roomStoreToken = this.props.roomViewStore.addListener(this._onRoomViewStoreUpdate);
this._onRoomViewStoreUpdate();
}
@@ -50,7 +49,7 @@ export default class ReplyPreview extends React.Component {
}
_onRoomViewStoreUpdate() {
- const event = RoomViewStore.getQuotingEvent();
+ const event = this.props.roomViewStore.getQuotingEvent();
if (this.state.event !== event) {
this.setState({ event });
}
diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js
index 4292fa6a4d..91ca73dd59 100644
--- a/src/components/views/rooms/RoomHeader.js
+++ b/src/components/views/rooms/RoomHeader.js
@@ -24,6 +24,7 @@ import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from "../../../Modal";
import RateLimitedFunc from '../../../ratelimitedfunc';
+import dis from '../../../dispatcher';
import * as linkify from 'linkifyjs';
import linkifyElement from 'linkifyjs/element';
@@ -152,6 +153,14 @@ module.exports = React.createClass({
});
},
+ onToggleRightPanelClick: function(ev) {
+ if (this.props.collapsedRhs) {
+ dis.dispatch({action: "show_right_panel"});
+ } else {
+ dis.dispatch({action: "hide_right_panel"});
+ }
+ },
+
_hasUnreadPins: function() {
const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", '');
if (!currentPinEvent) return false;
@@ -409,6 +418,17 @@ module.exports = React.createClass({
;
}
+ let toggleRightPanelButton;
+ if (this.props.isGrid) {
+ toggleRightPanelButton =
+
+
+ ;
+ }
+
return (
@@ -419,7 +439,8 @@ module.exports = React.createClass({
{ saveButton }
{ cancelButton }
{ rightRow }
-
+ { !this.props.isGrid ? : undefined }
+ { toggleRightPanelButton }
);
diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js
index cb3cb49cd4..1a714e3a84 100644
--- a/src/components/views/rooms/RoomList.js
+++ b/src/components/views/rooms/RoomList.js
@@ -102,6 +102,7 @@ module.exports = React.createClass({
cli.on("Event.decrypted", this.onEventDecrypted);
cli.on("accountData", this.onAccountData);
cli.on("Group.myMembership", this._onGroupMyMembership);
+ cli.on("RoomState.events", this.onRoomStateEvents);
const dmRoomMap = DMRoomMap.shared();
// A map between tags which are group IDs and the room IDs of rooms that should be kept
@@ -230,6 +231,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("Event.decrypted", this.onEventDecrypted);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
MatrixClientPeg.get().removeListener("Group.myMembership", this._onGroupMyMembership);
+ MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
}
if (this._tagStoreToken) {
@@ -253,6 +255,12 @@ module.exports = React.createClass({
this.updateVisibleRooms();
},
+ onRoomStateEvents: function(ev, state) {
+ if (ev.getType() === "m.room.create" || ev.getType() === "m.room.tombstone") {
+ this.updateVisibleRooms();
+ }
+ },
+
onDeleteRoom: function(roomId) {
this.updateVisibleRooms();
},
diff --git a/src/components/views/rooms/RoomRecoveryReminder.js b/src/components/views/rooms/RoomRecoveryReminder.js
index d03c5fc96d..01447012e6 100644
--- a/src/components/views/rooms/RoomRecoveryReminder.js
+++ b/src/components/views/rooms/RoomRecoveryReminder.js
@@ -1,5 +1,5 @@
/*
-Copyright 2018 New Vector Ltd
+Copyright 2018, 2019 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.
@@ -20,10 +20,16 @@ import sdk from "../../../index";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import MatrixClientPeg from "../../../MatrixClientPeg";
+import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
export default class RoomRecoveryReminder extends React.PureComponent {
static propTypes = {
- onFinished: PropTypes.func.isRequired,
+ // called if the user sets the option to suppress this reminder in the future
+ onDontAskAgainSet: PropTypes.func,
+ }
+
+ static defaultProps = {
+ onDontAskAgainSet: function() {},
}
constructor(props) {
@@ -82,7 +88,6 @@ export default class RoomRecoveryReminder extends React.PureComponent {
Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, {
userId: MatrixClientPeg.get().credentials.userId,
device: this.state.unverifiedDevice,
- onFinished: this.props.onFinished,
});
return;
}
@@ -91,9 +96,6 @@ export default class RoomRecoveryReminder extends React.PureComponent {
// we'll show the create key backup flow.
Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"),
- {
- onFinished: this.props.onFinished,
- },
);
}
@@ -103,10 +105,14 @@ export default class RoomRecoveryReminder extends React.PureComponent {
Modal.createTrackedDialogAsync("Ignore Recovery Reminder", "Ignore Recovery Reminder",
import("../../../async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog"),
{
- onDontAskAgain: () => {
- // Report false to the caller, who should prevent the
- // reminder from appearing in the future.
- this.props.onFinished(false);
+ onDontAskAgain: async () => {
+ await SettingsStore.setValue(
+ "showRoomRecoveryReminder",
+ null,
+ SettingLevel.ACCOUNT,
+ false,
+ );
+ this.props.onDontAskAgainSet();
},
onSetup: () => {
this.showSetupDialog();
diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js
index bce4d15f16..95073b7be8 100644
--- a/src/components/views/rooms/RoomTile.js
+++ b/src/components/views/rooms/RoomTile.js
@@ -29,7 +29,6 @@ import * as RoomNotifs from '../../../RoomNotifs';
import * as FormattingUtils from '../../../utils/FormattingUtils';
import AccessibleButton from '../elements/AccessibleButton';
import ActiveRoomObserver from '../../../ActiveRoomObserver';
-import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore";
module.exports = React.createClass({
@@ -62,7 +61,7 @@ module.exports = React.createClass({
roomName: this.props.room.name,
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
notificationCount: this.props.room.getUnreadNotificationCount(),
- selected: this.props.room.roomId === RoomViewStore.getRoomId(),
+ selected: this.props.room.roomId === ActiveRoomObserver.getActiveRoomId(),
});
},
@@ -117,9 +116,9 @@ module.exports = React.createClass({
}
},
- _onActiveRoomChange: function() {
+ _onActiveRoomChange: function(activeRoomId) {
this.setState({
- selected: this.props.room.roomId === RoomViewStore.getRoomId(),
+ selected: this.props.room.roomId === activeRoomId,
});
},
diff --git a/src/components/views/settings/KeyBackupPanel.js b/src/components/views/settings/KeyBackupPanel.js
index 03b98d28a0..b1c94c3067 100644
--- a/src/components/views/settings/KeyBackupPanel.js
+++ b/src/components/views/settings/KeyBackupPanel.js
@@ -21,13 +21,15 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
-export default class KeyBackupPanel extends React.Component {
+export default class KeyBackupPanel extends React.PureComponent {
constructor(props) {
super(props);
this._startNewBackup = this._startNewBackup.bind(this);
this._deleteBackup = this._deleteBackup.bind(this);
this._verifyDevice = this._verifyDevice.bind(this);
+ this._onKeyBackupSessionsRemaining =
+ this._onKeyBackupSessionsRemaining.bind(this);
this._onKeyBackupStatus = this._onKeyBackupStatus.bind(this);
this._restoreBackup = this._restoreBackup.bind(this);
@@ -36,6 +38,7 @@ export default class KeyBackupPanel extends React.Component {
loading: true,
error: null,
backupInfo: null,
+ sessionsRemaining: 0,
};
}
@@ -43,6 +46,10 @@ export default class KeyBackupPanel extends React.Component {
this._loadBackupStatus();
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatus);
+ MatrixClientPeg.get().on(
+ 'crypto.keyBackupSessionsRemaining',
+ this._onKeyBackupSessionsRemaining,
+ );
}
componentWillUnmount() {
@@ -50,9 +57,19 @@ export default class KeyBackupPanel extends React.Component {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatus);
+ MatrixClientPeg.get().removeListener(
+ 'crypto.keyBackupSessionsRemaining',
+ this._onKeyBackupSessionsRemaining,
+ );
}
}
+ _onKeyBackupSessionsRemaining(sessionsRemaining) {
+ this.setState({
+ sessionsRemaining,
+ });
+ }
+
_onKeyBackupStatus() {
this._loadBackupStatus();
}
@@ -144,57 +161,70 @@ export default class KeyBackupPanel extends React.Component {
} else if (this.state.backupInfo) {
let clientBackupStatus;
if (MatrixClientPeg.get().getKeyBackupEnabled()) {
- clientBackupStatus = _t("This device is uploading keys to this backup");
+ clientBackupStatus = _t("This device is using key backup");
} else {
// XXX: display why and how to fix it
clientBackupStatus = _t(
- "This device is
not uploading keys to this backup", {},
+ "This device is
not using key backup", {},
{b: x =>
{x}},
);
}
+ let uploadStatus;
+ const { sessionsRemaining } = this.state;
+ if (!MatrixClientPeg.get().getKeyBackupEnabled()) {
+ // No upload status to show when backup disabled.
+ uploadStatus = "";
+ } else if (sessionsRemaining > 0) {
+ uploadStatus =
+ {_t("Backing up %(sessionsRemaining)s keys...", { sessionsRemaining })}
+
;
+ } else {
+ uploadStatus =
+ {_t("All keys backed up")}
+
;
+ }
+
let backupSigStatuses = this.state.backupSigStatus.sigs.map((sig, i) => {
const deviceName = sig.device.getDisplayName() || sig.device.deviceId;
- const sigStatusSubstitutions = {
- validity: sub =>
-
- {sub}
- ,
- verify: sub =>
-
- {sub}
- ,
- device: sub =>
{deviceName},
- };
+ const validity = sub =>
+
+ {sub}
+ ;
+ const verify = sub =>
+
+ {sub}
+ ;
+ const device = sub =>
{deviceName};
let sigStatus;
if (sig.device.getFingerprint() === MatrixClientPeg.get().getDeviceEd25519Key()) {
sigStatus = _t(
"Backup has a
valid signature from this device",
- {}, sigStatusSubstitutions,
+ {}, { validity },
);
} else if (sig.valid && sig.device.isVerified()) {
sigStatus = _t(
"Backup has a
valid signature from " +
"
verified device
",
- {}, sigStatusSubstitutions,
+ {}, { validity, verify, device },
);
} else if (sig.valid && !sig.device.isVerified()) {
sigStatus = _t(
"Backup has a
valid signature from " +
"
unverified device
",
- {}, sigStatusSubstitutions,
+ {}, { validity, verify, device },
);
} else if (!sig.valid && sig.device.isVerified()) {
sigStatus = _t(
"Backup has an
invalid signature from " +
"
verified device
",
- {}, sigStatusSubstitutions,
+ {}, { validity, verify, device },
);
} else if (!sig.valid && !sig.device.isVerified()) {
sigStatus = _t(
"Backup has an
invalid signature from " +
"
unverified device
",
- {}, sigStatusSubstitutions,
+ {}, { validity, verify, device },
);
}
@@ -219,6 +249,7 @@ export default class KeyBackupPanel extends React.Component {
{_t("Backup version: ")}{this.state.backupInfo.version}
{_t("Algorithm: ")}{this.state.backupInfo.algorithm}
{clientBackupStatus}
+ {uploadStatus}
{backupSigStatuses}
{...}.
- * @param {boolean=} sync Optional. Pass true to dispatch
- * synchronously. This is useful for anything triggering
- * an operation that the browser requires user interaction
- * for.
- */
- dispatch(payload, sync) {
- // Allow for asynchronous dispatching by accepting payloads that have the
- // type `function (dispatch) {...}`
- if (typeof payload === 'function') {
- payload((action) => {
- this.dispatch(action, sync);
- });
- return;
- }
-
- if (sync) {
- super.dispatch(payload);
- } else {
- // Unless the caller explicitly asked for us to dispatch synchronously,
- // we always set a timeout to do this: The flux dispatcher complains
- // if you dispatch from within a dispatch, so rather than action
- // handlers having to worry about not calling anything that might
- // then dispatch, we just do dispatches asynchronously.
- setTimeout(super.dispatch.bind(this, payload), 0);
- }
- }
-}
+import MatrixDispatcher from "./matrix-dispatcher";
if (global.mxDispatcher === undefined) {
global.mxDispatcher = new MatrixDispatcher();
}
+
module.exports = global.mxDispatcher;
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index ef1b2e9162..0086ed0378 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -183,6 +183,7 @@
"%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s changed the topic to \"%(topic)s\".",
"%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s removed the room name.",
"%(senderDisplayName)s changed the room name to %(roomName)s.": "%(senderDisplayName)s changed the room name to %(roomName)s.",
+ "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s upgraded this room.",
"%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.",
"%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.|other": "%(senderName)s added %(addedAddresses)s as addresses for this room.",
"%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.|one": "%(senderName)s added %(addedAddresses)s as an address for this room.",
@@ -223,8 +224,10 @@
"Your browser does not support the required cryptography extensions": "Your browser does not support the required cryptography extensions",
"Not a valid Riot keyfile": "Not a valid Riot keyfile",
"Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?",
+ "Unrecognised address": "Unrecognised address",
"You do not have permission to invite people to this room.": "You do not have permission to invite people to this room.",
"User %(user_id)s does not exist": "User %(user_id)s does not exist",
+ "User %(user_id)s may or may not exist": "User %(user_id)s may or may not exist",
"Unknown server error": "Unknown server error",
"Use a few words, avoid common phrases": "Use a few words, avoid common phrases",
"No need for symbols, digits, or uppercase letters": "No need for symbols, digits, or uppercase letters",
@@ -261,6 +264,8 @@
"Custom user status messages": "Custom user status messages",
"Increase performance by only loading room members on first view": "Increase performance by only loading room members on first view",
"Backup of encryption keys to server": "Backup of encryption keys to server",
+ "Render simple counters in room header": "Render simple counters in room header",
+ "Allow up to 6 rooms in a community to be shown simultaneously in a grid via the context menu": "Allow up to 6 rooms in a community to be shown simultaneously in a grid via the context menu",
"Disable Emoji suggestions while typing": "Disable Emoji suggestions while typing",
"Use compact timeline layout": "Use compact timeline layout",
"Hide removed messages": "Hide removed messages",
@@ -292,6 +297,7 @@
"Pin unread rooms to the top of the room list": "Pin unread rooms to the top of the room list",
"Enable widget screenshots on supported widgets": "Enable widget screenshots on supported widgets",
"Show empty room list headings": "Show empty room list headings",
+ "Always invite users which may not exist": "Always invite users which may not exist",
"Show developer tools": "Show developer tools",
"Collecting app version information": "Collecting app version information",
"Collecting logs": "Collecting logs",
@@ -352,8 +358,10 @@
"Delete your backed up encryption keys from the server? You will no longer be able to use your recovery key to read encrypted message history": "Delete your backed up encryption keys from the server? You will no longer be able to use your recovery key to read encrypted message history",
"Delete backup": "Delete backup",
"Unable to load key backup status": "Unable to load key backup status",
- "This device is uploading keys to this backup": "This device is uploading keys to this backup",
- "This device is not uploading keys to this backup": "This device is not uploading keys to this backup",
+ "This device is using key backup": "This device is using key backup",
+ "This device is not using key backup": "This device is not using key backup",
+ "Backing up %(sessionsRemaining)s keys...": "Backing up %(sessionsRemaining)s keys...",
+ "All keys backed up": "All keys backed up",
"Backup has a valid signature from this device": "Backup has a valid signature from this device",
"Backup has a valid signature from verified device ": "Backup has a valid signature from verified device ",
"Backup has a valid signature from unverified device ": "Backup has a valid signature from unverified device ",
@@ -535,6 +543,7 @@
"Forget room": "Forget room",
"Search": "Search",
"Share room": "Share room",
+ "Toggle right panel": "Toggle right panel",
"Drop here to favourite": "Drop here to favourite",
"Drop here to tag direct chat": "Drop here to tag direct chat",
"Drop here to restore": "Drop here to restore",
@@ -883,6 +892,10 @@
"That doesn't look like a valid email address": "That doesn't look like a valid email address",
"You have entered an invalid address.": "You have entered an invalid address.",
"Try using one of the following valid address types: %(validTypesList)s.": "Try using one of the following valid address types: %(validTypesList)s.",
+ "The following users may not exist": "The following users may not exist",
+ "The following users may not exist - would you like to invite them anyways?": "The following users may not exist - would you like to invite them anyways?",
+ "Invite anyways and never warn me again": "Invite anyways and never warn me again",
+ "Invite anyways": "Invite anyways",
"Preparing to send logs": "Preparing to send logs",
"Logs sent": "Logs sent",
"Thank you!": "Thank you!",
@@ -1077,6 +1090,7 @@
"Direct Chat": "Direct Chat",
"Set a new status...": "Set a new status...",
"Clear status": "Clear status",
+ "View as Grid": "View as Grid",
"View Community": "View Community",
"Sorry, your browser is not able to run Riot.": "Sorry, your browser is not able to run Riot.",
"Riot uses many advanced browser features, some of which are not available or experimental in your current browser.": "Riot uses many advanced browser features, some of which are not available or experimental in your current browser.",
@@ -1087,6 +1101,7 @@
"You must register to use this functionality": "You must register to use this functionality",
"You must join the room to see its files": "You must join the room to see its files",
"There are no visible files in this room": "There are no visible files in this room",
+ "No room in this tile yet.": "No room in this tile yet.",
"HTML for your community's page
\n\n Use the long description to introduce new members to the community, or distribute\n some important links\n
\n\n You can even use 'img' tags\n
\n": "HTML for your community's page
\n\n Use the long description to introduce new members to the community, or distribute\n some important links\n
\n\n You can even use 'img' tags\n
\n",
"Add rooms to the community summary": "Add rooms to the community summary",
"Which rooms would you like to add to this summary?": "Which rooms would you like to add to this summary?",
@@ -1386,26 +1401,31 @@
"Print it and store it somewhere safe": "Print it and store it somewhere safe",
"Save it on a USB key or backup drive": "Save it on a USB key or backup drive",
"Copy it to your personal cloud storage": "Copy it to your personal cloud storage",
- "Backup created": "Backup created",
- "Your encryption keys are now being backed up to your Homeserver.": "Your encryption keys are now being backed up to your Homeserver.",
+ "Your encryption keys are now being backed up in the background to your Homeserver. The initial backup could take several minutes. You can view key backup upload progress in Settings.": "Your encryption keys are now being backed up in the background to your Homeserver. The initial backup could take several minutes. You can view key backup upload progress in Settings.",
"Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another device.": "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another device.",
"Set up Secure Message Recovery": "Set up Secure Message Recovery",
"Create a Recovery Passphrase": "Create a Recovery Passphrase",
"Confirm Recovery Passphrase": "Confirm Recovery Passphrase",
"Recovery Key": "Recovery Key",
"Keep it safe": "Keep it safe",
- "Backing up...": "Backing up...",
+ "Starting backup...": "Starting backup...",
+ "Backup Started": "Backup Started",
"Create Key Backup": "Create Key Backup",
"Unable to create key backup": "Unable to create key backup",
"Retry": "Retry",
"Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.",
"If you don't want to set this up now, you can later in Settings.": "If you don't want to set this up now, you can later in Settings.",
"New Recovery Method": "New Recovery Method",
- "A new recovery passphrase and key for Secure Messages has been detected.": "A new recovery passphrase and key for Secure Messages has been detected.",
- "Setting up Secure Messages on this device will re-encrypt this device's message history with the new recovery method.": "Setting up Secure Messages on this device will re-encrypt this device's message history with the new recovery method.",
+ "A new recovery passphrase and key for Secure Messages have been detected.": "A new recovery passphrase and key for Secure Messages have been detected.",
"If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.",
- "Set up Secure Messages": "Set up Secure Messages",
+ "This device is encrypting history using the new recovery method.": "This device is encrypting history using the new recovery method.",
"Go to Settings": "Go to Settings",
+ "Setting up Secure Messages on this device will re-encrypt this device's message history with the new recovery method.": "Setting up Secure Messages on this device will re-encrypt this device's message history with the new recovery method.",
+ "Set up Secure Messages": "Set up Secure Messages",
+ "Recovery Method Removed": "Recovery Method Removed",
+ "This device has detected that your recovery passphrase and key for Secure Messages have been removed.": "This device has detected that your recovery passphrase and key for Secure Messages have been removed.",
+ "If you did this accidentally, you can setup Secure Messages on this device which will re-encrypt this device's message history with a new recovery method.": "If you did this accidentally, you can setup Secure Messages on this device which will re-encrypt this device's message history with a new recovery method.",
+ "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.",
"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"
diff --git a/src/matrix-dispatcher.js b/src/matrix-dispatcher.js
new file mode 100644
index 0000000000..fb81ed837f
--- /dev/null
+++ b/src/matrix-dispatcher.js
@@ -0,0 +1,53 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 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.
+*/
+
+'use strict';
+
+const flux = require("flux");
+
+export default class MatrixDispatcher extends flux.Dispatcher {
+ /**
+ * @param {Object|function} payload Required. The payload to dispatch.
+ * If an Object, must contain at least an 'action' key.
+ * If a function, must have the signature (dispatch) => {...}.
+ * @param {boolean=} sync Optional. Pass true to dispatch
+ * synchronously. This is useful for anything triggering
+ * an operation that the browser requires user interaction
+ * for.
+ */
+ dispatch(payload, sync) {
+ // Allow for asynchronous dispatching by accepting payloads that have the
+ // type `function (dispatch) {...}`
+ if (typeof payload === 'function') {
+ payload((action) => {
+ this.dispatch(action, sync);
+ });
+ return;
+ }
+
+ if (sync) {
+ super.dispatch(payload);
+ } else {
+ // Unless the caller explicitly asked for us to dispatch synchronously,
+ // we always set a timeout to do this: The flux dispatcher complains
+ // if you dispatch from within a dispatch, so rather than action
+ // handlers having to worry about not calling anything that might
+ // then dispatch, we just do dispatches asynchronously.
+ setTimeout(super.dispatch.bind(this, payload), 0);
+ }
+ }
+}
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index 14f4bdc6dd..a04301e31e 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -102,6 +102,18 @@ export const SETTINGS = {
supportedLevels: LEVELS_FEATURE,
default: false,
},
+ "feature_state_counters": {
+ isFeature: true,
+ displayName: _td("Render simple counters in room header"),
+ supportedLevels: LEVELS_FEATURE,
+ default: false,
+ },
+ "feature_gridview": {
+ isFeature: true,
+ displayName: _td("Allow up to 6 rooms in a community to be shown simultaneously in a grid via the context menu"),
+ supportedLevels: LEVELS_FEATURE,
+ default: false,
+ },
"MessageComposerInput.dontSuggestEmoji": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Disable Emoji suggestions while typing'),
@@ -317,6 +329,11 @@ export const SETTINGS = {
displayName: _td('Show empty room list headings'),
default: true,
},
+ "alwaysInviteUnknownUsers": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ displayName: _td('Always invite users which may not exist'),
+ default: false,
+ },
"showDeveloperTools": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Show developer tools'),
diff --git a/src/stores/OpenRoomsStore.js b/src/stores/OpenRoomsStore.js
new file mode 100644
index 0000000000..21f02fe28d
--- /dev/null
+++ b/src/stores/OpenRoomsStore.js
@@ -0,0 +1,277 @@
+/*
+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 MatrixDispatcher from '../matrix-dispatcher';
+import dis from '../dispatcher';
+import {RoomViewStore} from './RoomViewStore';
+import GroupStore from './GroupStore';
+import {Store} from 'flux/utils';
+import MatrixClientPeg from '../MatrixClientPeg';
+
+
+function matchesRoom(payload, roomStore) {
+ if (!roomStore) {
+ return false;
+ }
+ if (payload.room_alias) {
+ return payload.room_alias === roomStore.getRoomAlias();
+ }
+ return payload.room_id === roomStore.getRoomId();
+}
+
+/**
+ * A class for keeping track of the RoomViewStores of the rooms shown on the screen.
+ * Routes the dispatcher actions to the store of currently active room.
+ */
+class OpenRoomsStore extends Store {
+ constructor() {
+ super(dis);
+
+ // Initialise state
+ this._state = {
+ rooms: [],
+ currentIndex: null,
+ group_id: null,
+ };
+
+ this._forwardingEvent = null;
+ }
+
+ getRoomStores() {
+ return this._state.rooms.map((r) => r.store);
+ }
+
+ getActiveRoomStore() {
+ const openRoom = this._getActiveOpenRoom();
+ if (openRoom) {
+ return openRoom.store;
+ }
+ }
+
+ getRoomStoreAt(index) {
+ if (index >= 0 && index < this._state.rooms.length) {
+ return this._state.rooms[index].store;
+ }
+ }
+
+ _getActiveOpenRoom() {
+ const index = this._state.currentIndex;
+ if (index !== null && index < this._state.rooms.length) {
+ return this._state.rooms[index];
+ }
+ }
+
+ _setState(newState) {
+ this._state = Object.assign(this._state, newState);
+ this.__emitChange();
+ }
+
+ _hasRoom(payload) {
+ return this._roomIndex(payload) !== -1;
+ }
+
+ _roomIndex(payload) {
+ return this._state.rooms.findIndex((r) => matchesRoom(payload, r.store));
+ }
+
+ _cleanupOpenRooms() {
+ this._state.rooms.forEach((room) => {
+ room.dispatcher.unregister(room.dispatcherRef);
+ room.dispatcher.unregister(room.store.getDispatchToken());
+ });
+ this._setState({
+ rooms: [],
+ group_id: null,
+ currentIndex: null,
+ });
+ }
+
+ _createOpenRoom(roomId, roomAlias) {
+ const dispatcher = new MatrixDispatcher();
+ // forward all actions coming from the room dispatcher
+ // to the global one
+ const dispatcherRef = dispatcher.register((payload) => {
+ // block a view_room action for the same room because it will switch to
+ // single room mode in MatrixChat
+ if (payload.action === 'view_room' && roomId === payload.room_id) {
+ return;
+ }
+ payload.grid_src_room_id = roomId;
+ payload.grid_src_room_alias = roomAlias;
+ this.getDispatcher().dispatch(payload);
+ });
+ const openRoom = {
+ store: new RoomViewStore(dispatcher),
+ dispatcher,
+ dispatcherRef,
+ };
+
+ dispatcher.dispatch({
+ action: 'view_room',
+ room_id: roomId,
+ room_alias: roomAlias,
+ }, true);
+
+ return openRoom;
+ }
+
+ _setSingleOpenRoom(payload) {
+ this._setState({
+ rooms: [this._createOpenRoom(payload.room_id, payload.room_alias)],
+ currentIndex: 0,
+ });
+ }
+
+ _setGroupOpenRooms(groupId) {
+ this._cleanupOpenRooms();
+ // TODO: register to GroupStore updates
+ const rooms = GroupStore.getGroupRooms(groupId);
+ const openRooms = rooms.map((room) => {
+ return this._createOpenRoom(room.roomId);
+ });
+ this._setState({
+ rooms: openRooms,
+ group_id: groupId,
+ currentIndex: 0,
+ });
+ }
+
+ _forwardAction(payload) {
+ // don't forward an event to a room dispatcher
+ // if the event originated from that dispatcher, as this
+ // would cause the event to be observed twice in that
+ // dispatcher
+ if (payload.grid_src_room_id || payload.grid_src_room_alias) {
+ const srcPayload = {
+ room_id: payload.grid_src_room_id,
+ room_alias: payload.grid_src_room_alias,
+ };
+ const srcIndex = this._roomIndex(srcPayload);
+ if (srcIndex === this._state.currentIndex) {
+ return;
+ }
+ }
+ const currentRoom = this._getActiveOpenRoom();
+ if (currentRoom) {
+ currentRoom.dispatcher.dispatch(payload, true);
+ }
+ }
+
+ async _resolveRoomAlias(payload) {
+ try {
+ const result = await MatrixClientPeg.get()
+ .getRoomIdForAlias(payload.room_alias);
+ this.getDispatcher().dispatch({
+ action: 'view_room',
+ room_id: result.room_id,
+ event_id: payload.event_id,
+ highlighted: payload.highlighted,
+ room_alias: payload.room_alias,
+ auto_join: payload.auto_join,
+ oob_data: payload.oob_data,
+ });
+ } catch (err) {
+ this._forwardAction({
+ action: 'view_room_error',
+ room_id: null,
+ room_alias: payload.room_alias,
+ err: err,
+ });
+ }
+ }
+
+ _viewRoom(payload) {
+ console.log("!!! OpenRoomsStore: view_room", payload);
+ if (!payload.room_id && payload.room_alias) {
+ this._resolveRoomAlias(payload);
+ }
+ const currentStore = this.getActiveRoomStore();
+ if (!matchesRoom(payload, currentStore)) {
+ if (this._hasRoom(payload)) {
+ const roomIndex = this._roomIndex(payload);
+ this._setState({currentIndex: roomIndex});
+ } else {
+ this._cleanupOpenRooms();
+ }
+ }
+ if (!this.getActiveRoomStore()) {
+ console.log("OpenRoomsStore: _setSingleOpenRoom");
+ this._setSingleOpenRoom(payload);
+ }
+ console.log("OpenRoomsStore: _forwardAction");
+ this._forwardAction(payload);
+ if (this._forwardingEvent) {
+ this.getDispatcher().dispatch({
+ action: 'send_event',
+ room_id: payload.room_id,
+ event: this._forwardingEvent,
+ });
+ this._forwardingEvent = null;
+ }
+ }
+
+ __onDispatch(payload) {
+ let proposedIndex;
+ switch (payload.action) {
+ // view_room:
+ // - room_alias: '#somealias:matrix.org'
+ // - room_id: '!roomid123:matrix.org'
+ // - event_id: '$213456782:matrix.org'
+ // - event_offset: 100
+ // - highlighted: true
+ case 'view_room':
+ this._viewRoom(payload);
+ break;
+ case 'view_my_groups':
+ case 'view_group':
+ this._forwardAction(payload);
+ this._cleanupOpenRooms();
+ break;
+ case 'will_join':
+ case 'cancel_join':
+ case 'join_room':
+ case 'join_room_error':
+ case 'on_logged_out':
+ case 'reply_to_event':
+ case 'open_room_settings':
+ case 'close_settings':
+ case 'focus_composer':
+ this._forwardAction(payload);
+ break;
+ case 'forward_event':
+ this._forwardingEvent = payload.event;
+ break;
+ case 'group_grid_set_active':
+ proposedIndex = this._roomIndex(payload);
+ if (proposedIndex !== -1) {
+ this._setState({
+ currentIndex: proposedIndex,
+ });
+ }
+ break;
+ case 'group_grid_view':
+ if (payload.group_id !== this._state.group_id) {
+ this._setGroupOpenRooms(payload.group_id);
+ }
+ break;
+ }
+ }
+}
+
+let singletonOpenRoomsStore = null;
+if (!singletonOpenRoomsStore) {
+ singletonOpenRoomsStore = new OpenRoomsStore();
+}
+module.exports = singletonOpenRoomsStore;
diff --git a/src/stores/RoomViewStore.js b/src/stores/RoomViewStore.js
index 9e048e5d8e..a0b831ad17 100644
--- a/src/stores/RoomViewStore.js
+++ b/src/stores/RoomViewStore.js
@@ -14,7 +14,6 @@ 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 dis from '../dispatcher';
import {Store} from 'flux/utils';
import MatrixClientPeg from '../MatrixClientPeg';
import sdk from '../index';
@@ -53,12 +52,12 @@ const INITIAL_STATE = {
* with a subset of the js-sdk.
* ```
*/
-class RoomViewStore extends Store {
- constructor() {
- super(dis);
+export class RoomViewStore extends Store {
+ constructor(dispatcher) {
+ super(dispatcher);
// Initialise state
- this._state = INITIAL_STATE;
+ this._state = Object.assign({}, INITIAL_STATE);
}
_setState(newState) {
@@ -85,6 +84,8 @@ class RoomViewStore extends Store {
});
break;
case 'view_room_error':
+ // should not go over dispatcher anymore
+ // but be internal to RoomViewStore
this._viewRoomError(payload);
break;
case 'will_join':
@@ -150,22 +151,11 @@ class RoomViewStore extends Store {
// pull the user out of Room Settings
isEditingSettings: false,
};
-
- if (this._state.forwardingEvent) {
- dis.dispatch({
- action: 'send_event',
- room_id: newState.roomId,
- event: this._state.forwardingEvent,
- });
- }
-
this._setState(newState);
-
if (payload.auto_join) {
this._joinRoom(payload);
}
} else if (payload.room_alias) {
- // Resolve the alias and then do a second dispatch with the room ID acquired
this._setState({
roomId: null,
initialEventId: null,
@@ -175,25 +165,6 @@ class RoomViewStore extends Store {
roomLoading: true,
roomLoadError: null,
});
- MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias).done(
- (result) => {
- dis.dispatch({
- action: 'view_room',
- room_id: result.room_id,
- event_id: payload.event_id,
- highlighted: payload.highlighted,
- room_alias: payload.room_alias,
- auto_join: payload.auto_join,
- oob_data: payload.oob_data,
- });
- }, (err) => {
- dis.dispatch({
- action: 'view_room_error',
- room_id: null,
- room_alias: payload.room_alias,
- err: err,
- });
- });
}
}
@@ -219,7 +190,7 @@ class RoomViewStore extends Store {
// stream yet, and that's the point at which we'd consider
// the user joined to the room.
}, (err) => {
- dis.dispatch({
+ this.getDispatcher().dispatch({
action: 'join_room_error',
err: err,
});
@@ -335,8 +306,7 @@ class RoomViewStore extends Store {
}
}
-let singletonRoomViewStore = null;
-if (!singletonRoomViewStore) {
- singletonRoomViewStore = new RoomViewStore();
-}
-module.exports = singletonRoomViewStore;
+const MatrixDispatcher = require("../matrix-dispatcher");
+const backwardsCompatInstance = new RoomViewStore(new MatrixDispatcher());
+
+export default backwardsCompatInstance;
diff --git a/src/utils/MultiInviter.js b/src/utils/MultiInviter.js
index ad10f28edf..b5f4f960a9 100644
--- a/src/utils/MultiInviter.js
+++ b/src/utils/MultiInviter.js
@@ -15,11 +15,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+import React from "react";
import MatrixClientPeg from '../MatrixClientPeg';
import {getAddressType} from '../UserAddress';
import GroupStore from '../stores/GroupStore';
import Promise from 'bluebird';
import {_t} from "../languageHandler";
+import sdk from "../index";
+import Modal from "../Modal";
+import SettingsStore from "../settings/SettingsStore";
/**
* Invites multiple addresses to a room or group, handling rate limiting from the server
@@ -41,7 +45,7 @@ export default class MultiInviter {
this.addrs = [];
this.busy = false;
this.completionStates = {}; // State of each address (invited or error)
- this.errorTexts = {}; // Textual error per address
+ this.errors = {}; // { address: {errorText, errcode} }
this.deferred = null;
}
@@ -61,7 +65,10 @@ export default class MultiInviter {
for (const addr of this.addrs) {
if (getAddressType(addr) === null) {
this.completionStates[addr] = 'error';
- this.errorTexts[addr] = 'Unrecognised address';
+ this.errors[addr] = {
+ errcode: 'M_INVALID',
+ errorText: _t('Unrecognised address'),
+ };
}
}
this.deferred = Promise.defer();
@@ -85,18 +92,28 @@ export default class MultiInviter {
}
getErrorText(addr) {
- return this.errorTexts[addr];
+ return this.errors[addr] ? this.errors[addr].errorText : null;
}
- async _inviteToRoom(roomId, addr) {
+ async _inviteToRoom(roomId, addr, ignoreProfile) {
const addrType = getAddressType(addr);
if (addrType === 'email') {
return MatrixClientPeg.get().inviteByEmail(roomId, addr);
} else if (addrType === 'mx-user-id') {
- const profile = await MatrixClientPeg.get().getProfileInfo(addr);
- if (!profile) {
- return Promise.reject({errcode: "M_NOT_FOUND", error: "User does not have a profile."});
+ if (!ignoreProfile && !SettingsStore.getValue("alwaysInviteUnknownUsers", this.roomId)) {
+ try {
+ const profile = await MatrixClientPeg.get().getProfileInfo(addr);
+ if (!profile) {
+ // noinspection ExceptionCaughtLocallyJS
+ throw new Error("User has no profile");
+ }
+ } catch (e) {
+ throw {
+ errcode: "RIOT.USER_NOT_FOUND",
+ error: "User does not have a profile or does not exist."
+ };
+ }
}
return MatrixClientPeg.get().invite(roomId, addr);
@@ -105,14 +122,109 @@ export default class MultiInviter {
}
}
+ _doInvite(address, ignoreProfile) {
+ return new Promise((resolve, reject) => {
+ console.log(`Inviting ${address}`);
- _inviteMore(nextIndex) {
+ let doInvite;
+ if (this.groupId !== null) {
+ doInvite = GroupStore.inviteUserToGroup(this.groupId, address);
+ } else {
+ doInvite = this._inviteToRoom(this.roomId, address, ignoreProfile);
+ }
+
+ doInvite.then(() => {
+ if (this._canceled) {
+ return;
+ }
+
+ this.completionStates[address] = 'invited';
+ delete this.errors[address];
+
+ resolve();
+ }).catch((err) => {
+ if (this._canceled) {
+ return;
+ }
+
+ let errorText;
+ let fatal = false;
+ if (err.errcode === 'M_FORBIDDEN') {
+ fatal = true;
+ errorText = _t('You do not have permission to invite people to this room.');
+ } else if (err.errcode === 'M_LIMIT_EXCEEDED') {
+ // we're being throttled so wait a bit & try again
+ setTimeout(() => {
+ this._doInvite(address, ignoreProfile).then(resolve, reject);
+ }, 5000);
+ return;
+ } else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'RIOT.USER_NOT_FOUND'].includes(err.errcode)) {
+ errorText = _t("User %(user_id)s does not exist", {user_id: address});
+ } else if (err.errcode === 'M_PROFILE_UNDISCLOSED') {
+ errorText = _t("User %(user_id)s may or may not exist", {user_id: address});
+ } else if (err.errcode === 'M_PROFILE_NOT_FOUND' && !ignoreProfile) {
+ // Invite without the profile check
+ console.warn(`User ${address} does not have a profile - inviting anyways automatically`);
+ this._doInvite(address, true).then(resolve, reject);
+ } else {
+ errorText = _t('Unknown server error');
+ }
+
+ this.completionStates[address] = 'error';
+ this.errors[address] = {errorText, errcode: err.errcode};
+
+ this.busy = !fatal;
+ this.fatal = fatal;
+
+ if (fatal) {
+ reject();
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+
+ _inviteMore(nextIndex, ignoreProfile) {
if (this._canceled) {
return;
}
if (nextIndex === this.addrs.length) {
this.busy = false;
+ if (Object.keys(this.errors).length > 0 && !this.groupId) {
+ // There were problems inviting some people - see if we can invite them
+ // without caring if they exist or not.
+ const unknownProfileErrors = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UNDISCLOSED', 'M_PROFILE_NOT_FOUND', 'RIOT.USER_NOT_FOUND'];
+ const unknownProfileUsers = Object.keys(this.errors).filter(a => unknownProfileErrors.includes(this.errors[a].errcode));
+
+ if (unknownProfileUsers.length > 0) {
+ const inviteUnknowns = () => {
+ const promises = unknownProfileUsers.map(u => this._doInvite(u, true));
+ Promise.all(promises).then(() => this.deferred.resolve(this.completionStates));
+ };
+
+ if (SettingsStore.getValue("alwaysInviteUnknownUsers", this.roomId)) {
+ inviteUnknowns();
+ return;
+ }
+
+ const AskInviteAnywayDialog = sdk.getComponent("dialogs.AskInviteAnywayDialog");
+ console.log("Showing failed to invite dialog...");
+ Modal.createTrackedDialog('Failed to invite the following users to the room', '', AskInviteAnywayDialog, {
+ unknownProfileUsers: unknownProfileUsers.map(u => {return {userId: u, errorText: this.errors[u].errorText};}),
+ onInviteAnyways: () => inviteUnknowns(),
+ onGiveUp: () => {
+ // Fake all the completion states because we already warned the user
+ for (const addr of unknownProfileUsers) {
+ this.completionStates[addr] = 'invited';
+ }
+ this.deferred.resolve(this.completionStates);
+ },
+ });
+ return;
+ }
+ }
this.deferred.resolve(this.completionStates);
return;
}
@@ -134,48 +246,8 @@ export default class MultiInviter {
return;
}
- let doInvite;
- if (this.groupId !== null) {
- doInvite = GroupStore.inviteUserToGroup(this.groupId, addr);
- } else {
- doInvite = this._inviteToRoom(this.roomId, addr);
- }
-
- doInvite.then(() => {
- if (this._canceled) { return; }
-
- this.completionStates[addr] = 'invited';
-
- this._inviteMore(nextIndex + 1);
- }).catch((err) => {
- if (this._canceled) { return; }
-
- let errorText;
- let fatal = false;
- if (err.errcode === 'M_FORBIDDEN') {
- fatal = true;
- errorText = _t('You do not have permission to invite people to this room.');
- } else if (err.errcode === 'M_LIMIT_EXCEEDED') {
- // we're being throttled so wait a bit & try again
- setTimeout(() => {
- this._inviteMore(nextIndex);
- }, 5000);
- return;
- } else if(err.errcode === "M_NOT_FOUND") {
- errorText = _t("User %(user_id)s does not exist", {user_id: addr});
- } else {
- errorText = _t('Unknown server error');
- }
- this.completionStates[addr] = 'error';
- this.errorTexts[addr] = errorText;
- this.busy = !fatal;
- this.fatal = fatal;
-
- if (!fatal) {
- this._inviteMore(nextIndex + 1);
- } else {
- this.deferred.resolve(this.completionStates);
- }
- });
+ this._doInvite(addr, ignoreProfile).then(() => {
+ this._inviteMore(nextIndex + 1, ignoreProfile);
+ }).catch(() => this.deferred.resolve(this.completionStates));
}
}
diff --git a/src/utils/Timer.js b/src/utils/Timer.js
index aeac0887c9..ca06237fbf 100644
--- a/src/utils/Timer.js
+++ b/src/utils/Timer.js
@@ -26,7 +26,6 @@ Once a timer is finished or aborted, it can't be started again
a new one through `clone()` or `cloneIfRun()`.
*/
export default class Timer {
-
constructor(timeout) {
this._timeout = timeout;
this._onTimeout = this._onTimeout.bind(this);
@@ -70,6 +69,7 @@ export default class Timer {
/**
* if not started before, starts the timer.
+ * @returns {Timer} the same timer
*/
start() {
if (!this.isRunning()) {
@@ -81,6 +81,7 @@ export default class Timer {
/**
* (re)start the timer. If it's running, reset the timeout. If not, start it.
+ * @returns {Timer} the same timer
*/
restart() {
if (this.isRunning()) {
@@ -98,6 +99,7 @@ export default class Timer {
/**
* if the timer is running, abort it,
* and reject the promise for this timer.
+ * @returns {Timer} the same timer
*/
abort() {
if (this.isRunning()) {