diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss
index 80d2cd3418..525855f3ed 100644
--- a/res/css/views/rooms/_EventTile.scss
+++ b/res/css/views/rooms/_EventTile.scss
@@ -392,6 +392,7 @@ limitations under the License.
overflow-x: overlay;
overflow-y: visible;
max-height: 30vh;
+ position: static;
}
.mx_EventTile_content .markdown-body code {
@@ -406,7 +407,7 @@ limitations under the License.
visibility: hidden;
cursor: pointer;
top: 6px;
- right: 6px;
+ right: 36px;
width: 19px;
height: 19px;
background-image: url($copy-button-url);
diff --git a/src/ContentMessages.js b/src/ContentMessages.js
index 7fe625f8b9..fd21977108 100644
--- a/src/ContentMessages.js
+++ b/src/ContentMessages.js
@@ -243,6 +243,7 @@ function uploadFile(matrixClient, roomId, file, progressHandler) {
const blob = new Blob([encryptResult.data]);
return matrixClient.uploadContent(blob, {
progressHandler: progressHandler,
+ includeFilename: false,
}).then(function(url) {
// If the attachment is encrypted then bundle the URL along
// with the information needed to decrypt the attachment and
diff --git a/src/DecryptionFailureTracker.js b/src/DecryptionFailureTracker.js
new file mode 100644
index 0000000000..b1c6a71289
--- /dev/null
+++ b/src/DecryptionFailureTracker.js
@@ -0,0 +1,169 @@
+/*
+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.
+*/
+
+class DecryptionFailure {
+ constructor(failedEventId) {
+ this.failedEventId = failedEventId;
+ this.ts = Date.now();
+ }
+}
+
+export default class DecryptionFailureTracker {
+ // Array of items of type DecryptionFailure. Every `CHECK_INTERVAL_MS`, this list
+ // is checked for failures that happened > `GRACE_PERIOD_MS` ago. Those that did
+ // are added to `failuresToTrack`.
+ failures = [];
+
+ // Every TRACK_INTERVAL_MS (so as to spread the number of hits done on Analytics),
+ // one DecryptionFailure of this FIFO is removed and tracked.
+ failuresToTrack = [];
+
+ // Event IDs of failures that were tracked previously
+ trackedEventHashMap = {
+ // [eventId]: true
+ };
+
+ // Set to an interval ID when `start` is called
+ checkInterval = null;
+ trackInterval = null;
+
+ // Spread the load on `Analytics` by sending at most 1 event per
+ // `TRACK_INTERVAL_MS`.
+ static TRACK_INTERVAL_MS = 1000;
+
+ // Call `checkFailures` every `CHECK_INTERVAL_MS`.
+ static CHECK_INTERVAL_MS = 5000;
+
+ // Give events a chance to be decrypted by waiting `GRACE_PERIOD_MS` before moving
+ // the failure to `failuresToTrack`.
+ static GRACE_PERIOD_MS = 5000;
+
+ constructor(fn) {
+ if (!fn || typeof fn !== 'function') {
+ throw new Error('DecryptionFailureTracker requires tracking function');
+ }
+
+ this.trackDecryptionFailure = fn;
+ }
+
+ // loadTrackedEventHashMap() {
+ // this.trackedEventHashMap = JSON.parse(localStorage.getItem('mx-decryption-failure-event-id-hashes')) || {};
+ // }
+
+ // saveTrackedEventHashMap() {
+ // localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap));
+ // }
+
+ eventDecrypted(e) {
+ if (e.isDecryptionFailure()) {
+ this.addDecryptionFailureForEvent(e);
+ } else {
+ // Could be an event in the failures, remove it
+ this.removeDecryptionFailuresForEvent(e);
+ }
+ }
+
+ addDecryptionFailureForEvent(e) {
+ this.failures.push(new DecryptionFailure(e.getId()));
+ }
+
+ removeDecryptionFailuresForEvent(e) {
+ this.failures = this.failures.filter((f) => f.failedEventId !== e.getId());
+ }
+
+ /**
+ * Start checking for and tracking failures.
+ */
+ start() {
+ this.checkInterval = setInterval(
+ () => this.checkFailures(Date.now()),
+ DecryptionFailureTracker.CHECK_INTERVAL_MS,
+ );
+
+ this.trackInterval = setInterval(
+ () => this.trackFailure(),
+ DecryptionFailureTracker.TRACK_INTERVAL_MS,
+ );
+ }
+
+ /**
+ * Clear state and stop checking for and tracking failures.
+ */
+ stop() {
+ clearInterval(this.checkInterval);
+ clearInterval(this.trackInterval);
+
+ this.failures = [];
+ this.failuresToTrack = [];
+ }
+
+ /**
+ * Mark failures that occured before nowTs - GRACE_PERIOD_MS as failures that should be
+ * tracked. Only mark one failure per event ID.
+ * @param {number} nowTs the timestamp that represents the time now.
+ */
+ checkFailures(nowTs) {
+ const failuresGivenGrace = [];
+ const failuresNotReady = [];
+ while (this.failures.length > 0) {
+ const f = this.failures.shift();
+ if (nowTs > f.ts + DecryptionFailureTracker.GRACE_PERIOD_MS) {
+ failuresGivenGrace.push(f);
+ } else {
+ failuresNotReady.push(f);
+ }
+ }
+ this.failures = failuresNotReady;
+
+ // Only track one failure per event
+ const dedupedFailuresMap = failuresGivenGrace.reduce(
+ (map, failure) => {
+ if (!this.trackedEventHashMap[failure.failedEventId]) {
+ return map.set(failure.failedEventId, failure);
+ } else {
+ return map;
+ }
+ },
+ // Use a map to preseve key ordering
+ new Map(),
+ );
+
+ const trackedEventIds = [...dedupedFailuresMap.keys()];
+
+ this.trackedEventHashMap = trackedEventIds.reduce(
+ (result, eventId) => ({...result, [eventId]: true}),
+ this.trackedEventHashMap,
+ );
+
+ // Commented out for now for expediency, we need to consider unbound nature of storing
+ // this in localStorage
+ // this.saveTrackedEventHashMap();
+
+ const dedupedFailures = dedupedFailuresMap.values();
+
+ this.failuresToTrack = [...this.failuresToTrack, ...dedupedFailures];
+ }
+
+ /**
+ * If there is a failure that should be tracked, call the given trackDecryptionFailure
+ * function with the first failure in the FIFO of failures that should be tracked.
+ */
+ trackFailure() {
+ if (this.failuresToTrack.length > 0) {
+ this.trackDecryptionFailure(this.failuresToTrack.shift());
+ }
+ }
+}
diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js
index e33fa7861f..b162f2f92a 100644
--- a/src/autocomplete/CommandProvider.js
+++ b/src/autocomplete/CommandProvider.js
@@ -2,6 +2,7 @@
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
+Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -21,6 +22,7 @@ import { _t, _td } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import FuzzyMatcher from './FuzzyMatcher';
import {TextualCompletion} from './Components';
+import type {SelectionRange} from "./Autocompleter";
// TODO merge this with the factory mechanics of SlashCommands?
// Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file
@@ -110,10 +112,9 @@ const COMMANDS = [
args: '',
description: _td('Opens the Developer Tools dialog'),
},
- // Omitting `/markdown` as it only seems to apply to OldComposer
];
-const COMMAND_RE = /(^\/\w*)/g;
+const COMMAND_RE = /(^\/\w*)(?: .*)?/g;
export default class CommandProvider extends AutocompleteProvider {
constructor() {
@@ -123,23 +124,24 @@ export default class CommandProvider extends AutocompleteProvider {
});
}
- async getCompletions(query: string, selection: {start: number, end: number}) {
- let completions = [];
+ async getCompletions(query: string, selection: SelectionRange, force?: boolean) {
const {command, range} = this.getCurrentCommand(query, selection);
- if (command) {
- completions = this.matcher.match(command[0]).map((result) => {
- return {
- completion: result.command + ' ',
- component: (),
- range,
- };
- });
- }
- return completions;
+ if (!command) return [];
+
+ // if the query is just `/` (and the user hit TAB or waits), show them all COMMANDS otherwise FuzzyMatch them
+ const matches = query === '/' ? COMMANDS : this.matcher.match(command[1]);
+ return matches.map((result) => {
+ return {
+ // If the command is the same as the one they entered, we don't want to discard their arguments
+ completion: result.command === command[1] ? command[0] : (result.command + ' '),
+ component: (),
+ range,
+ };
+ });
}
getName() {
diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js
index 3249cae22c..927449750c 100644
--- a/src/components/structures/FilePanel.js
+++ b/src/components/structures/FilePanel.js
@@ -68,8 +68,8 @@ const FilePanel = React.createClass({
"room": {
"timeline": {
"contains_url": true,
- "not_types": [
- "m.sticker",
+ "types": [
+ "m.room.message",
],
},
},
diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js
index a343d1c971..801d2e282e 100644
--- a/src/components/structures/GroupView.js
+++ b/src/components/structures/GroupView.js
@@ -1059,7 +1059,7 @@ export default React.createClass({
{ _t('Only people who have been invited') }
@@ -1071,7 +1071,7 @@ export default React.createClass({
{ _t('Everyone') }
@@ -1134,10 +1134,6 @@ export default React.createClass({
let avatarNode;
let nameNode;
let shortDescNode;
- const bodyNodes = [
- this._getMembershipSection(),
- this._getGroupSection(),
- ];
const rightButtons = [];
if (this.state.editing && this.state.isUserPrivileged) {
let avatarImage;
@@ -1282,7 +1278,8 @@ export default React.createClass({
- { bodyNodes }
+ { this._getMembershipSection() }
+ { this._getGroupSection() }
);
diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js
index 7b115a6c4b..ebe5d7f507 100644
--- a/src/components/structures/LeftPanel.js
+++ b/src/components/structures/LeftPanel.js
@@ -94,6 +94,12 @@ var LeftPanel = React.createClass({
case KeyCode.DOWN:
this._onMoveFocus(false);
break;
+ case KeyCode.ENTER:
+ this._onMoveFocus(false);
+ if (this.focusedElement) {
+ this.focusedElement.click();
+ }
+ break;
default:
handled = false;
}
@@ -105,37 +111,33 @@ var LeftPanel = React.createClass({
},
_onMoveFocus: function(up) {
- var element = this.focusedElement;
+ let element = this.focusedElement;
// unclear why this isn't needed
// var descending = (up == this.focusDirection) ? this.focusDescending : !this.focusDescending;
// this.focusDirection = up;
- var descending = false; // are we currently descending or ascending through the DOM tree?
- var classes;
+ let descending = false; // are we currently descending or ascending through the DOM tree?
+ let classes;
do {
- var child = up ? element.lastElementChild : element.firstElementChild;
- var sibling = up ? element.previousElementSibling : element.nextElementSibling;
+ const child = up ? element.lastElementChild : element.firstElementChild;
+ const sibling = up ? element.previousElementSibling : element.nextElementSibling;
if (descending) {
if (child) {
element = child;
- }
- else if (sibling) {
+ } else if (sibling) {
element = sibling;
- }
- else {
+ } else {
descending = false;
element = element.parentElement;
}
- }
- else {
+ } else {
if (sibling) {
element = sibling;
descending = true;
- }
- else {
+ } else {
element = element.parentElement;
}
}
@@ -147,8 +149,7 @@ var LeftPanel = React.createClass({
descending = true;
}
}
-
- } while(element && !(
+ } while (element && !(
classes.contains("mx_RoomTile") ||
classes.contains("mx_SearchBox_search") ||
classes.contains("mx_RoomSubList_ellipsis")));
diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js
index 50a1f70496..4b8b75ad74 100644
--- a/src/components/structures/MatrixChat.js
+++ b/src/components/structures/MatrixChat.js
@@ -23,6 +23,7 @@ import PropTypes from 'prop-types';
import Matrix from "matrix-js-sdk";
import Analytics from "../../Analytics";
+import DecryptionFailureTracker from "../../DecryptionFailureTracker";
import MatrixClientPeg from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
@@ -1303,6 +1304,21 @@ export default React.createClass({
}
});
+ const dft = new DecryptionFailureTracker((failure) => {
+ // TODO: Pass reason for failure as third argument to trackEvent
+ Analytics.trackEvent('E2E', 'Decryption failure');
+ });
+
+ // Shelved for later date when we have time to think about persisting history of
+ // tracked events across sessions.
+ // dft.loadTrackedEventHashMap();
+
+ dft.start();
+
+ // When logging out, stop tracking failures and destroy state
+ cli.on("Session.logged_out", () => dft.stop());
+ cli.on("Event.decrypted", (e) => dft.eventDecrypted(e));
+
const krh = new KeyRequestHandler(cli);
cli.on("crypto.roomKeyRequest", (req) => {
krh.handleKeyRequest(req);
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js
index 50bdb37734..f5fa2ceabf 100644
--- a/src/components/structures/MessagePanel.js
+++ b/src/components/structures/MessagePanel.js
@@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
+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.
@@ -25,6 +26,9 @@ import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg';
+const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
+const continuedTypes = ['m.sticker', 'm.room.message'];
+
/* (almost) stateless UI component which builds the event tiles in the room timeline.
*/
module.exports = React.createClass({
@@ -189,7 +193,7 @@ module.exports = React.createClass({
/**
* Page up/down.
*
- * mult: -1 to page up, +1 to page down
+ * @param {number} mult: -1 to page up, +1 to page down
*/
scrollRelative: function(mult) {
if (this.refs.scrollPanel) {
@@ -199,6 +203,8 @@ module.exports = React.createClass({
/**
* Scroll up/down in response to a scroll key
+ *
+ * @param {KeyboardEvent} ev: the keyboard event to handle
*/
handleScrollKey: function(ev) {
if (this.refs.scrollPanel) {
@@ -257,6 +263,7 @@ module.exports = React.createClass({
this.eventNodes = {};
+ let visible = false;
let i;
// first figure out which is the last event in the list which we're
@@ -297,7 +304,7 @@ module.exports = React.createClass({
// if the readmarker has moved, cancel any active ghost.
if (this.currentReadMarkerEventId && this.props.readMarkerEventId &&
this.props.readMarkerVisible &&
- this.currentReadMarkerEventId != this.props.readMarkerEventId) {
+ this.currentReadMarkerEventId !== this.props.readMarkerEventId) {
this.currentGhostEventId = null;
}
@@ -404,8 +411,8 @@ module.exports = React.createClass({
let isVisibleReadMarker = false;
- if (eventId == this.props.readMarkerEventId) {
- var visible = this.props.readMarkerVisible;
+ if (eventId === this.props.readMarkerEventId) {
+ visible = this.props.readMarkerVisible;
// if the read marker comes at the end of the timeline (except
// for local echoes, which are excluded from RMs, because they
@@ -423,11 +430,11 @@ module.exports = React.createClass({
// XXX: there should be no need for a ghost tile - we should just use a
// a dispatch (user_activity_end) to start the RM animation.
- if (eventId == this.currentGhostEventId) {
+ if (eventId === this.currentGhostEventId) {
// if we're showing an animation, continue to show it.
ret.push(this._getReadMarkerGhostTile());
} else if (!isVisibleReadMarker &&
- eventId == this.currentReadMarkerEventId) {
+ eventId === this.currentReadMarkerEventId) {
// there is currently a read-up-to marker at this point, but no
// more. Show an animation of it disappearing.
ret.push(this._getReadMarkerGhostTile());
@@ -449,16 +456,17 @@ module.exports = React.createClass({
// Some events should appear as continuations from previous events of
// different types.
- const continuedTypes = ['m.sticker', 'm.room.message'];
+
const eventTypeContinues =
prevEvent !== null &&
continuedTypes.includes(mxEv.getType()) &&
continuedTypes.includes(prevEvent.getType());
- if (prevEvent !== null
- && prevEvent.sender && mxEv.sender
- && mxEv.sender.userId === prevEvent.sender.userId
- && (mxEv.getType() == prevEvent.getType() || eventTypeContinues)) {
+ // if there is a previous event and it has the same sender as this event
+ // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
+ if (prevEvent !== null && prevEvent.sender && mxEv.sender && mxEv.sender.userId === prevEvent.sender.userId &&
+ (mxEv.getType() === prevEvent.getType() || eventTypeContinues) &&
+ (mxEv.getTs() - prevEvent.getTs() <= CONTINUATION_MAX_INTERVAL)) {
continuation = true;
}
@@ -493,7 +501,7 @@ module.exports = React.createClass({
}
const eventId = mxEv.getId();
- const highlight = (eventId == this.props.highlightedEventId);
+ const highlight = (eventId === this.props.highlightedEventId);
// we can't use local echoes as scroll tokens, because their event IDs change.
// Local echos have a send "status".
@@ -632,7 +640,8 @@ module.exports = React.createClass({
render: function() {
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
const Spinner = sdk.getComponent("elements.Spinner");
- let topSpinner, bottomSpinner;
+ let topSpinner;
+ let bottomSpinner;
if (this.props.backPaginating) {
topSpinner =
+ onPrimaryButtonClick={this.props.onNewDMClick} focus={true} />
;
}
diff --git a/src/components/views/elements/PersistedElement.js b/src/components/views/elements/PersistedElement.js
index c4bac27b4e..8115a36eeb 100644
--- a/src/components/views/elements/PersistedElement.js
+++ b/src/components/views/elements/PersistedElement.js
@@ -36,7 +36,7 @@ function getOrCreateContainer() {
}
// Greater than that of the ContextualMenu
-const PE_Z_INDEX = 3000;
+const PE_Z_INDEX = 5000;
/*
* Class of component that renders its children in a separate ReactDOM virtual tree
diff --git a/src/components/views/groups/GroupInviteTile.js b/src/components/views/groups/GroupInviteTile.js
index 4d5f3c6f3a..25dba130f9 100644
--- a/src/components/views/groups/GroupInviteTile.js
+++ b/src/components/views/groups/GroupInviteTile.js
@@ -1,5 +1,6 @@
/*
Copyright 2017, 2018 New Vector Ltd
+Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -20,8 +21,9 @@ import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
-import * as ContextualMenu from "../../structures/ContextualMenu";
import classNames from 'classnames';
+import MatrixClientPeg from "../../../MatrixClientPeg";
+import {createMenu} from "../../structures/ContextualMenu";
export default React.createClass({
displayName: 'GroupInviteTile',
@@ -66,29 +68,11 @@ export default React.createClass({
});
},
- onBadgeClicked: function(e) {
- // Prevent the RoomTile onClick event firing as well
- e.stopPropagation();
+ _showContextMenu: function(x, y, chevronOffset) {
+ const GroupInviteTileContextMenu = sdk.getComponent('context_menus.GroupInviteTileContextMenu');
- // Only allow none guests to access the context menu
- if (this.context.matrixClient.isGuest()) return;
-
- // If the badge is clicked, then no longer show tooltip
- if (this.props.collapsed) {
- this.setState({ hover: false });
- }
-
- const RoomTileContextMenu = sdk.getComponent('context_menus.GroupInviteTileContextMenu');
- 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
-
- ContextualMenu.createMenu(RoomTileContextMenu, {
- chevronOffset: chevronOffset,
+ createMenu(GroupInviteTileContextMenu, {
+ chevronOffset,
left: x,
top: y,
group: this.props.group,
@@ -99,6 +83,38 @@ export default React.createClass({
this.setState({ menuDisplayed: true });
},
+ onContextMenu: function(e) {
+ // Prevent the RoomTile onClick event firing as well
+ e.preventDefault();
+ // Only allow non-guests to access the context menu
+ if (MatrixClientPeg.get().isGuest()) return;
+
+ const chevronOffset = 12;
+ this._showContextMenu(e.clientX, e.clientY - (chevronOffset + 8), chevronOffset);
+ },
+
+ onBadgeClicked: function(e) {
+ // Prevent the RoomTile onClick event firing as well
+ e.stopPropagation();
+ // Only allow non-guests to access the context menu
+ if (MatrixClientPeg.get().isGuest()) return;
+
+ // If the badge is clicked, then no longer show tooltip
+ if (this.props.collapsed) {
+ this.setState({ hover: false });
+ }
+
+ 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
+
+ this._showContextMenu(x, y, chevronOffset);
+ },
+
render: function() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const EmojiText = sdk.getComponent('elements.EmojiText');
@@ -139,7 +155,12 @@ export default React.createClass({
});
return (
-
+
{ av }
diff --git a/src/components/views/groups/GroupPublicityToggle.js b/src/components/views/groups/GroupPublicityToggle.js
index 78522c2f55..ff0fc553b8 100644
--- a/src/components/views/groups/GroupPublicityToggle.js
+++ b/src/components/views/groups/GroupPublicityToggle.js
@@ -69,7 +69,7 @@ export default React.createClass({
render() {
const GroupTile = sdk.getComponent('groups.GroupTile');
const input = ;
const labelText = !this.state.ready ? _t("Loading...") :
diff --git a/src/components/views/groups/GroupTile.js b/src/components/views/groups/GroupTile.js
index c1554cd9ed..509c209baa 100644
--- a/src/components/views/groups/GroupTile.js
+++ b/src/components/views/groups/GroupTile.js
@@ -22,6 +22,7 @@ import sdk from '../../../index';
import dis from '../../../dispatcher';
import FlairStore from '../../../stores/FlairStore';
+function nop() {}
const GroupTile = React.createClass({
displayName: 'GroupTile',
@@ -81,7 +82,7 @@ const GroupTile = React.createClass({
) : null;
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/riot-web/issues/6156
- return
+ return
{ (droppableProvided, droppableSnapshot) => (
diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js
index 246ea6891f..292ac25d42 100644
--- a/src/components/views/messages/MFileBody.js
+++ b/src/components/views/messages/MFileBody.js
@@ -327,6 +327,7 @@ module.exports = React.createClass({
// will have the correct name when the user tries to download it.
// We can't provide a Content-Disposition header like we would for HTTP.
download: fileName,
+ rel: "noopener",
target: "_blank",
textContent: _t("Download %(text)s", { text: text }),
}, "*");
diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js
index 018754411c..60377a47d7 100644
--- a/src/components/views/messages/TextualBody.js
+++ b/src/components/views/messages/TextualBody.js
@@ -36,6 +36,7 @@ import * as ContextualMenu from '../../structures/ContextualMenu';
import SettingsStore from "../../../settings/SettingsStore";
import PushProcessor from 'matrix-js-sdk/lib/pushprocessor';
import ReplyThread from "../elements/ReplyThread";
+import {host as matrixtoHost} from '../../../matrix-to';
linkifyMatrix(linkify);
@@ -304,7 +305,7 @@ module.exports = React.createClass({
// never preview matrix.to links (if anything we should give a smart
// preview of the room/user they point to: nobody needs to be reminded
// what the matrix.to site looks like).
- if (host == 'matrix.to') return false;
+ if (host === matrixtoHost) return false;
if (node.textContent.toLowerCase().trim().startsWith(host.toLowerCase())) {
// it's a "foo.pl" style link
diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js
index 97e8780f0f..57d433e55c 100644
--- a/src/components/views/rooms/MessageComposerInput.js
+++ b/src/components/views/rooms/MessageComposerInput.js
@@ -45,8 +45,7 @@ import Markdown from '../../../Markdown';
import ComposerHistoryManager from '../../../ComposerHistoryManager';
import MessageComposerStore from '../../../stores/MessageComposerStore';
-import {MATRIXTO_URL_PATTERN, MATRIXTO_MD_LINK_PATTERN} from '../../../linkify-matrix';
-const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
+import {MATRIXTO_MD_LINK_PATTERN} from '../../../linkify-matrix';
const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g');
import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione';
diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js
index 11eb2090f2..ee7f8a76c7 100644
--- a/src/components/views/rooms/RoomTile.js
+++ b/src/components/views/rooms/RoomTile.js
@@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
+Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,19 +16,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-'use strict';
-const React = require('react');
-const ReactDOM = require("react-dom");
+import React from 'react';
import PropTypes from 'prop-types';
-const classNames = require('classnames');
+import classNames from 'classnames';
import dis from '../../../dispatcher';
-const MatrixClientPeg = require('../../../MatrixClientPeg');
+import MatrixClientPeg from '../../../MatrixClientPeg';
import DMRoomMap from '../../../utils/DMRoomMap';
-const sdk = require('../../../index');
-const ContextualMenu = require('../../structures/ContextualMenu');
-const RoomNotifs = require('../../../RoomNotifs');
-const FormattingUtils = require('../../../utils/FormattingUtils');
+import sdk from '../../../index';
+import {createMenu} from '../../structures/ContextualMenu';
+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';
@@ -72,16 +71,12 @@ module.exports = React.createClass({
},
_shouldShowMentionBadge: function() {
- return this.state.notifState != RoomNotifs.MUTE;
+ return this.state.notifState !== RoomNotifs.MUTE;
},
_isDirectMessageRoom: function(roomId) {
const dmRooms = DMRoomMap.shared().getUserIdForRoomId(roomId);
- if (dmRooms) {
- return true;
- } else {
- return false;
- }
+ return Boolean(dmRooms);
},
onRoomTimeline: function(ev, room) {
@@ -99,7 +94,7 @@ module.exports = React.createClass({
},
onAccountData: function(accountDataEvent) {
- if (accountDataEvent.getType() == 'm.push_rules') {
+ if (accountDataEvent.getType() === 'm.push_rules') {
this.setState({
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
});
@@ -187,6 +182,32 @@ module.exports = React.createClass({
this.badgeOnMouseLeave();
},
+ _showContextMenu: function(x, y, chevronOffset) {
+ const RoomTileContextMenu = sdk.getComponent('context_menus.RoomTileContextMenu');
+
+ createMenu(RoomTileContextMenu, {
+ chevronOffset,
+ left: x,
+ top: y,
+ room: this.props.room,
+ onFinished: () => {
+ this.setState({ menuDisplayed: false });
+ this.props.refreshSubList();
+ },
+ });
+ this.setState({ menuDisplayed: true });
+ },
+
+ onContextMenu: function(e) {
+ // Prevent the RoomTile onClick event firing as well
+ e.preventDefault();
+ // Only allow non-guests to access the context menu
+ if (MatrixClientPeg.get().isGuest()) return;
+
+ const chevronOffset = 12;
+ this._showContextMenu(e.clientX, e.clientY - (chevronOffset + 8), chevronOffset);
+ },
+
badgeOnMouseEnter: function() {
// Only allow non-guests to access the context menu
// and only change it if it needs to change
@@ -200,37 +221,25 @@ module.exports = React.createClass({
},
onBadgeClicked: function(e) {
- // Only allow none guests to access the context menu
- if (!MatrixClientPeg.get().isGuest()) {
- // If the badge is clicked, then no longer show tooltip
- if (this.props.collapsed) {
- this.setState({ hover: false });
- }
-
- const RoomTileContextMenu = sdk.getComponent('context_menus.RoomTileContextMenu');
- 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(RoomTileContextMenu, {
- chevronOffset: chevronOffset,
- left: x,
- top: y,
- room: this.props.room,
- onFinished: function() {
- self.setState({ menuDisplayed: false });
- self.props.refreshSubList();
- },
- });
- this.setState({ menuDisplayed: true });
- }
// Prevent the RoomTile onClick event firing as well
e.stopPropagation();
+ // Only allow non-guests to access the context menu
+ if (MatrixClientPeg.get().isGuest()) return;
+
+ // If the badge is clicked, then no longer show tooltip
+ if (this.props.collapsed) {
+ this.setState({ hover: false });
+ }
+
+ 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
+
+ this._showContextMenu(x, y, chevronOffset);
},
render: function() {
@@ -250,7 +259,7 @@ module.exports = React.createClass({
'mx_RoomTile_unread': this.props.unread,
'mx_RoomTile_unreadNotify': notifBadges,
'mx_RoomTile_highlight': mentionBadges,
- 'mx_RoomTile_invited': (me && me.membership == 'invite'),
+ 'mx_RoomTile_invited': (me && me.membership === 'invite'),
'mx_RoomTile_menuDisplayed': this.state.menuDisplayed,
'mx_RoomTile_noBadges': !badges,
'mx_RoomTile_transparent': this.props.transparent,
@@ -268,7 +277,6 @@ module.exports = React.createClass({
let name = this.state.roomName;
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
- let badge;
let badgeContent;
if (this.state.badgeHover || this.state.menuDisplayed) {
@@ -280,7 +288,7 @@ module.exports = React.createClass({
badgeContent = '\u200B';
}
- badge =
{ badgeContent }
;
+ const badge =
{ badgeContent }
;
const EmojiText = sdk.getComponent('elements.EmojiText');
let label;
@@ -312,16 +320,22 @@ module.exports = React.createClass({
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
- let directMessageIndicator;
+ let dmIndicator;
if (this._isDirectMessageRoom(this.props.room.roomId)) {
- directMessageIndicator = ;
+ dmIndicator = ;
}
- return
+ return
- { directMessageIndicator }
+ { dmIndicator }
diff --git a/src/linkify-matrix.js b/src/linkify-matrix.js
index 6bbea77733..d72319948a 100644
--- a/src/linkify-matrix.js
+++ b/src/linkify-matrix.js
@@ -169,11 +169,18 @@ matrixLinkify.VECTOR_URL_PATTERN = "^(?:https?:\/\/)?(?:"
+ "(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/"
+ ")(#.*)";
-matrixLinkify.MATRIXTO_URL_PATTERN = "^(?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/((#|@|!).*)";
+matrixLinkify.MATRIXTO_URL_PATTERN = "^(?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/(([#@!+]).*)";
matrixLinkify.MATRIXTO_MD_LINK_PATTERN =
- '\\[([^\\]]*)\\]\\((?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/((#|@|!)[^\\)]*)\\)';
+ '\\[([^\\]]*)\\]\\((?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/([#@!+][^\\)]*)\\)';
matrixLinkify.MATRIXTO_BASE_URL= baseUrl;
+const matrixToEntityMap = {
+ '@': '#/user/',
+ '#': '#/room/',
+ '!': '#/room/',
+ '+': '#/group/',
+};
+
matrixLinkify.options = {
events: function(href, type) {
switch (type) {
@@ -204,24 +211,20 @@ matrixLinkify.options = {
case 'userid':
case 'groupid':
return matrixLinkify.MATRIXTO_BASE_URL + '/#/' + href;
- default:
- var m;
+ default: {
// FIXME: horrible duplication with HtmlUtils' transform tags
- m = href.match(matrixLinkify.VECTOR_URL_PATTERN);
+ let m = href.match(matrixLinkify.VECTOR_URL_PATTERN);
if (m) {
return m[1];
}
m = href.match(matrixLinkify.MATRIXTO_URL_PATTERN);
if (m) {
const entity = m[1];
- if (entity[0] === '@') {
- return '#/user/' + entity;
- } else if (entity[0] === '#' || entity[0] === '!') {
- return '#/room/' + entity;
- }
+ if (matrixToEntityMap[entity[0]]) return matrixToEntityMap[entity[0]] + entity;
}
return href;
+ }
}
},
diff --git a/src/matrix-to.js b/src/matrix-to.js
index 72fb3c38fc..90b0a66090 100644
--- a/src/matrix-to.js
+++ b/src/matrix-to.js
@@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-export const baseUrl = "https://matrix.to";
+export const host = "matrix.to";
+export const baseUrl = `https://${host}`;
export function makeEventPermalink(roomId, eventId) {
return `${baseUrl}/#/${roomId}/${eventId}`;
diff --git a/test/DecryptionFailureTracker-test.js b/test/DecryptionFailureTracker-test.js
new file mode 100644
index 0000000000..c4f3116cba
--- /dev/null
+++ b/test/DecryptionFailureTracker-test.js
@@ -0,0 +1,185 @@
+/*
+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 expect from 'expect';
+
+import DecryptionFailureTracker from '../src/DecryptionFailureTracker';
+
+import { MatrixEvent } from 'matrix-js-sdk';
+
+function createFailedDecryptionEvent() {
+ const event = new MatrixEvent({
+ event_id: "event-id-" + Math.random().toString(16).slice(2),
+ });
+ event._setClearData(
+ event._badEncryptedMessage(":("),
+ );
+ return event;
+}
+
+describe('DecryptionFailureTracker', function() {
+ it('tracks a failed decryption', function(done) {
+ const failedDecryptionEvent = createFailedDecryptionEvent();
+ let trackedFailure = null;
+ const tracker = new DecryptionFailureTracker((failure) => {
+ trackedFailure = failure;
+ });
+
+ tracker.eventDecrypted(failedDecryptionEvent);
+
+ // Pretend "now" is Infinity
+ tracker.checkFailures(Infinity);
+
+ // Immediately track the newest failure, if there is one
+ tracker.trackFailure();
+
+ expect(trackedFailure).toNotBe(null, 'should track a failure for an event that failed decryption');
+
+ done();
+ });
+
+ it('does not track a failed decryption where the event is subsequently successfully decrypted', (done) => {
+ const decryptedEvent = createFailedDecryptionEvent();
+ const tracker = new DecryptionFailureTracker((failure) => {
+ expect(true).toBe(false, 'should not track an event that has since been decrypted correctly');
+ });
+
+ tracker.eventDecrypted(decryptedEvent);
+
+ // Indicate successful decryption: clear data can be anything where the msgtype is not m.bad.encrypted
+ decryptedEvent._setClearData({});
+ tracker.eventDecrypted(decryptedEvent);
+
+ // Pretend "now" is Infinity
+ tracker.checkFailures(Infinity);
+
+ // Immediately track the newest failure, if there is one
+ tracker.trackFailure();
+ done();
+ });
+
+ it('only tracks a single failure per event, despite multiple failed decryptions for multiple events', (done) => {
+ const decryptedEvent = createFailedDecryptionEvent();
+ const decryptedEvent2 = createFailedDecryptionEvent();
+
+ let count = 0;
+ const tracker = new DecryptionFailureTracker((failure) => count++);
+
+ // Arbitrary number of failed decryptions for both events
+ tracker.eventDecrypted(decryptedEvent);
+ tracker.eventDecrypted(decryptedEvent);
+ tracker.eventDecrypted(decryptedEvent);
+ tracker.eventDecrypted(decryptedEvent);
+ tracker.eventDecrypted(decryptedEvent);
+ tracker.eventDecrypted(decryptedEvent2);
+ tracker.eventDecrypted(decryptedEvent2);
+ tracker.eventDecrypted(decryptedEvent2);
+
+ // Pretend "now" is Infinity
+ tracker.checkFailures(Infinity);
+
+ // Simulated polling of `trackFailure`, an arbitrary number ( > 2 ) times
+ tracker.trackFailure();
+ tracker.trackFailure();
+ tracker.trackFailure();
+ tracker.trackFailure();
+
+ expect(count).toBe(2, count + ' failures tracked, should only track a single failure per event');
+
+ done();
+ });
+
+ it('track failures in the order they occured', (done) => {
+ const decryptedEvent = createFailedDecryptionEvent();
+ const decryptedEvent2 = createFailedDecryptionEvent();
+
+ const failures = [];
+ const tracker = new DecryptionFailureTracker((failure) => failures.push(failure));
+
+ // Indicate decryption
+ tracker.eventDecrypted(decryptedEvent);
+ tracker.eventDecrypted(decryptedEvent2);
+
+ // Pretend "now" is Infinity
+ tracker.checkFailures(Infinity);
+
+ // Simulated polling of `trackFailure`, an arbitrary number ( > 2 ) times
+ tracker.trackFailure();
+ tracker.trackFailure();
+
+ expect(failures.length).toBe(2, 'expected 2 failures to be tracked, got ' + failures.length);
+ expect(failures[0].failedEventId).toBe(decryptedEvent.getId(), 'the first failure should be tracked first');
+ expect(failures[1].failedEventId).toBe(decryptedEvent2.getId(), 'the second failure should be tracked second');
+
+ done();
+ });
+
+ it('should not track a failure for an event that was tracked previously', (done) => {
+ const decryptedEvent = createFailedDecryptionEvent();
+
+ const failures = [];
+ const tracker = new DecryptionFailureTracker((failure) => failures.push(failure));
+
+ // Indicate decryption
+ tracker.eventDecrypted(decryptedEvent);
+
+ // Pretend "now" is Infinity
+ tracker.checkFailures(Infinity);
+
+ tracker.trackFailure();
+
+ // Indicate a second decryption, after having tracked the failure
+ tracker.eventDecrypted(decryptedEvent);
+
+ tracker.trackFailure();
+
+ expect(failures.length).toBe(1, 'should only track a single failure per event');
+
+ done();
+ });
+
+ xit('should not track a failure for an event that was tracked in a previous session', (done) => {
+ // This test uses localStorage, clear it beforehand
+ localStorage.clear();
+
+ const decryptedEvent = createFailedDecryptionEvent();
+
+ const failures = [];
+ const tracker = new DecryptionFailureTracker((failure) => failures.push(failure));
+
+ // Indicate decryption
+ tracker.eventDecrypted(decryptedEvent);
+
+ // Pretend "now" is Infinity
+ // NB: This saves to localStorage specific to DFT
+ tracker.checkFailures(Infinity);
+
+ tracker.trackFailure();
+
+ // Simulate the browser refreshing by destroying tracker and creating a new tracker
+ const secondTracker = new DecryptionFailureTracker((failure) => failures.push(failure));
+
+ //secondTracker.loadTrackedEventHashMap();
+
+ secondTracker.eventDecrypted(decryptedEvent);
+ secondTracker.checkFailures(Infinity);
+ secondTracker.trackFailure();
+
+ expect(failures.length).toBe(1, 'should track a single failure per event per session, got ' + failures.length);
+
+ done();
+ });
+});