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 =
  • ; } diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 7a93cfb886..edb50fcedb 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -70,7 +70,7 @@ export default withMatrixClient(React.createClass({ if (this.state.groups) { const groupNodes = []; this.state.groups.forEach((g) => { - groupNodes.push(); + groupNodes.push(); }); contentHeader = groupNodes.length > 0 ?

    { _t('Your Communities') }

    :
    ; content = groupNodes.length > 0 ? @@ -124,7 +124,7 @@ export default withMatrixClient(React.createClass({ ) }
    -
    + {/*
    @@ -140,7 +140,7 @@ export default withMatrixClient(React.createClass({ { 'i': (sub) => { sub } }) }
    -
    + */}
    { contentHeader } diff --git a/src/components/views/dialogs/ChatCreateOrReuseDialog.js b/src/components/views/dialogs/ChatCreateOrReuseDialog.js index 95fd8848ba..9228e59280 100644 --- a/src/components/views/dialogs/ChatCreateOrReuseDialog.js +++ b/src/components/views/dialogs/ChatCreateOrReuseDialog.js @@ -170,7 +170,7 @@ export default class ChatCreateOrReuseDialog extends React.Component { { profile }
    + 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 = dm; + dmIndicator = dm; } - 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(); + }); +});