Merge branch 'develop' into t3chguy/ctrl-k_tab
This commit is contained in:
commit
6301c04590
22 changed files with 575 additions and 153 deletions
|
@ -392,6 +392,7 @@ limitations under the License.
|
||||||
overflow-x: overlay;
|
overflow-x: overlay;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
max-height: 30vh;
|
max-height: 30vh;
|
||||||
|
position: static;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_EventTile_content .markdown-body code {
|
.mx_EventTile_content .markdown-body code {
|
||||||
|
@ -406,7 +407,7 @@ limitations under the License.
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
right: 6px;
|
right: 36px;
|
||||||
width: 19px;
|
width: 19px;
|
||||||
height: 19px;
|
height: 19px;
|
||||||
background-image: url($copy-button-url);
|
background-image: url($copy-button-url);
|
||||||
|
|
|
@ -243,6 +243,7 @@ function uploadFile(matrixClient, roomId, file, progressHandler) {
|
||||||
const blob = new Blob([encryptResult.data]);
|
const blob = new Blob([encryptResult.data]);
|
||||||
return matrixClient.uploadContent(blob, {
|
return matrixClient.uploadContent(blob, {
|
||||||
progressHandler: progressHandler,
|
progressHandler: progressHandler,
|
||||||
|
includeFilename: false,
|
||||||
}).then(function(url) {
|
}).then(function(url) {
|
||||||
// If the attachment is encrypted then bundle the URL along
|
// If the attachment is encrypted then bundle the URL along
|
||||||
// with the information needed to decrypt the attachment and
|
// with the information needed to decrypt the attachment and
|
||||||
|
|
169
src/DecryptionFailureTracker.js
Normal file
169
src/DecryptionFailureTracker.js
Normal file
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@
|
||||||
Copyright 2016 Aviral Dasgupta
|
Copyright 2016 Aviral Dasgupta
|
||||||
Copyright 2017 Vector Creations Ltd
|
Copyright 2017 Vector Creations Ltd
|
||||||
Copyright 2017 New Vector Ltd
|
Copyright 2017 New Vector Ltd
|
||||||
|
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -21,6 +22,7 @@ import { _t, _td } from '../languageHandler';
|
||||||
import AutocompleteProvider from './AutocompleteProvider';
|
import AutocompleteProvider from './AutocompleteProvider';
|
||||||
import FuzzyMatcher from './FuzzyMatcher';
|
import FuzzyMatcher from './FuzzyMatcher';
|
||||||
import {TextualCompletion} from './Components';
|
import {TextualCompletion} from './Components';
|
||||||
|
import type {SelectionRange} from "./Autocompleter";
|
||||||
|
|
||||||
// TODO merge this with the factory mechanics of SlashCommands?
|
// 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
|
// 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: '',
|
args: '',
|
||||||
description: _td('Opens the Developer Tools dialog'),
|
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 {
|
export default class CommandProvider extends AutocompleteProvider {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -123,23 +124,24 @@ export default class CommandProvider extends AutocompleteProvider {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCompletions(query: string, selection: {start: number, end: number}) {
|
async getCompletions(query: string, selection: SelectionRange, force?: boolean) {
|
||||||
let completions = [];
|
|
||||||
const {command, range} = this.getCurrentCommand(query, selection);
|
const {command, range} = this.getCurrentCommand(query, selection);
|
||||||
if (command) {
|
if (!command) return [];
|
||||||
completions = this.matcher.match(command[0]).map((result) => {
|
|
||||||
return {
|
// if the query is just `/` (and the user hit TAB or waits), show them all COMMANDS otherwise FuzzyMatch them
|
||||||
completion: result.command + ' ',
|
const matches = query === '/' ? COMMANDS : this.matcher.match(command[1]);
|
||||||
component: (<TextualCompletion
|
return matches.map((result) => {
|
||||||
title={result.command}
|
return {
|
||||||
subtitle={result.args}
|
// If the command is the same as the one they entered, we don't want to discard their arguments
|
||||||
description={_t(result.description)}
|
completion: result.command === command[1] ? command[0] : (result.command + ' '),
|
||||||
/>),
|
component: (<TextualCompletion
|
||||||
range,
|
title={result.command}
|
||||||
};
|
subtitle={result.args}
|
||||||
});
|
description={_t(result.description)}
|
||||||
}
|
/>),
|
||||||
return completions;
|
range,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getName() {
|
getName() {
|
||||||
|
|
|
@ -68,8 +68,8 @@ const FilePanel = React.createClass({
|
||||||
"room": {
|
"room": {
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"contains_url": true,
|
"contains_url": true,
|
||||||
"not_types": [
|
"types": [
|
||||||
"m.sticker",
|
"m.room.message",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -1059,7 +1059,7 @@ export default React.createClass({
|
||||||
<input type="radio"
|
<input type="radio"
|
||||||
value={GROUP_JOINPOLICY_INVITE}
|
value={GROUP_JOINPOLICY_INVITE}
|
||||||
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_INVITE}
|
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_INVITE}
|
||||||
onClick={this._onJoinableChange}
|
onChange={this._onJoinableChange}
|
||||||
/>
|
/>
|
||||||
<div className="mx_GroupView_label_text">
|
<div className="mx_GroupView_label_text">
|
||||||
{ _t('Only people who have been invited') }
|
{ _t('Only people who have been invited') }
|
||||||
|
@ -1071,7 +1071,7 @@ export default React.createClass({
|
||||||
<input type="radio"
|
<input type="radio"
|
||||||
value={GROUP_JOINPOLICY_OPEN}
|
value={GROUP_JOINPOLICY_OPEN}
|
||||||
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_OPEN}
|
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_OPEN}
|
||||||
onClick={this._onJoinableChange}
|
onChange={this._onJoinableChange}
|
||||||
/>
|
/>
|
||||||
<div className="mx_GroupView_label_text">
|
<div className="mx_GroupView_label_text">
|
||||||
{ _t('Everyone') }
|
{ _t('Everyone') }
|
||||||
|
@ -1134,10 +1134,6 @@ export default React.createClass({
|
||||||
let avatarNode;
|
let avatarNode;
|
||||||
let nameNode;
|
let nameNode;
|
||||||
let shortDescNode;
|
let shortDescNode;
|
||||||
const bodyNodes = [
|
|
||||||
this._getMembershipSection(),
|
|
||||||
this._getGroupSection(),
|
|
||||||
];
|
|
||||||
const rightButtons = [];
|
const rightButtons = [];
|
||||||
if (this.state.editing && this.state.isUserPrivileged) {
|
if (this.state.editing && this.state.isUserPrivileged) {
|
||||||
let avatarImage;
|
let avatarImage;
|
||||||
|
@ -1282,7 +1278,8 @@ export default React.createClass({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GeminiScrollbarWrapper className="mx_GroupView_body">
|
<GeminiScrollbarWrapper className="mx_GroupView_body">
|
||||||
{ bodyNodes }
|
{ this._getMembershipSection() }
|
||||||
|
{ this._getGroupSection() }
|
||||||
</GeminiScrollbarWrapper>
|
</GeminiScrollbarWrapper>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -94,6 +94,12 @@ var LeftPanel = React.createClass({
|
||||||
case KeyCode.DOWN:
|
case KeyCode.DOWN:
|
||||||
this._onMoveFocus(false);
|
this._onMoveFocus(false);
|
||||||
break;
|
break;
|
||||||
|
case KeyCode.ENTER:
|
||||||
|
this._onMoveFocus(false);
|
||||||
|
if (this.focusedElement) {
|
||||||
|
this.focusedElement.click();
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
handled = false;
|
handled = false;
|
||||||
}
|
}
|
||||||
|
@ -105,37 +111,33 @@ var LeftPanel = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
_onMoveFocus: function(up) {
|
_onMoveFocus: function(up) {
|
||||||
var element = this.focusedElement;
|
let element = this.focusedElement;
|
||||||
|
|
||||||
// unclear why this isn't needed
|
// unclear why this isn't needed
|
||||||
// var descending = (up == this.focusDirection) ? this.focusDescending : !this.focusDescending;
|
// var descending = (up == this.focusDirection) ? this.focusDescending : !this.focusDescending;
|
||||||
// this.focusDirection = up;
|
// this.focusDirection = up;
|
||||||
|
|
||||||
var descending = false; // are we currently descending or ascending through the DOM tree?
|
let descending = false; // are we currently descending or ascending through the DOM tree?
|
||||||
var classes;
|
let classes;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
var child = up ? element.lastElementChild : element.firstElementChild;
|
const child = up ? element.lastElementChild : element.firstElementChild;
|
||||||
var sibling = up ? element.previousElementSibling : element.nextElementSibling;
|
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
|
||||||
|
|
||||||
if (descending) {
|
if (descending) {
|
||||||
if (child) {
|
if (child) {
|
||||||
element = child;
|
element = child;
|
||||||
}
|
} else if (sibling) {
|
||||||
else if (sibling) {
|
|
||||||
element = sibling;
|
element = sibling;
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
descending = false;
|
descending = false;
|
||||||
element = element.parentElement;
|
element = element.parentElement;
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
if (sibling) {
|
if (sibling) {
|
||||||
element = sibling;
|
element = sibling;
|
||||||
descending = true;
|
descending = true;
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
element = element.parentElement;
|
element = element.parentElement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -147,8 +149,7 @@ var LeftPanel = React.createClass({
|
||||||
descending = true;
|
descending = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} while (element && !(
|
||||||
} while(element && !(
|
|
||||||
classes.contains("mx_RoomTile") ||
|
classes.contains("mx_RoomTile") ||
|
||||||
classes.contains("mx_SearchBox_search") ||
|
classes.contains("mx_SearchBox_search") ||
|
||||||
classes.contains("mx_RoomSubList_ellipsis")));
|
classes.contains("mx_RoomSubList_ellipsis")));
|
||||||
|
|
|
@ -23,6 +23,7 @@ import PropTypes from 'prop-types';
|
||||||
import Matrix from "matrix-js-sdk";
|
import Matrix from "matrix-js-sdk";
|
||||||
|
|
||||||
import Analytics from "../../Analytics";
|
import Analytics from "../../Analytics";
|
||||||
|
import DecryptionFailureTracker from "../../DecryptionFailureTracker";
|
||||||
import MatrixClientPeg from "../../MatrixClientPeg";
|
import MatrixClientPeg from "../../MatrixClientPeg";
|
||||||
import PlatformPeg from "../../PlatformPeg";
|
import PlatformPeg from "../../PlatformPeg";
|
||||||
import SdkConfig from "../../SdkConfig";
|
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);
|
const krh = new KeyRequestHandler(cli);
|
||||||
cli.on("crypto.roomKeyRequest", (req) => {
|
cli.on("crypto.roomKeyRequest", (req) => {
|
||||||
krh.handleKeyRequest(req);
|
krh.handleKeyRequest(req);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2016 OpenMarket Ltd
|
Copyright 2016 OpenMarket Ltd
|
||||||
|
Copyright 2018 New Vector Ltd
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -25,6 +26,9 @@ import sdk from '../../index';
|
||||||
|
|
||||||
import MatrixClientPeg from '../../MatrixClientPeg';
|
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.
|
/* (almost) stateless UI component which builds the event tiles in the room timeline.
|
||||||
*/
|
*/
|
||||||
module.exports = React.createClass({
|
module.exports = React.createClass({
|
||||||
|
@ -189,7 +193,7 @@ module.exports = React.createClass({
|
||||||
/**
|
/**
|
||||||
* Page up/down.
|
* 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) {
|
scrollRelative: function(mult) {
|
||||||
if (this.refs.scrollPanel) {
|
if (this.refs.scrollPanel) {
|
||||||
|
@ -199,6 +203,8 @@ module.exports = React.createClass({
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll up/down in response to a scroll key
|
* Scroll up/down in response to a scroll key
|
||||||
|
*
|
||||||
|
* @param {KeyboardEvent} ev: the keyboard event to handle
|
||||||
*/
|
*/
|
||||||
handleScrollKey: function(ev) {
|
handleScrollKey: function(ev) {
|
||||||
if (this.refs.scrollPanel) {
|
if (this.refs.scrollPanel) {
|
||||||
|
@ -257,6 +263,7 @@ module.exports = React.createClass({
|
||||||
|
|
||||||
this.eventNodes = {};
|
this.eventNodes = {};
|
||||||
|
|
||||||
|
let visible = false;
|
||||||
let i;
|
let i;
|
||||||
|
|
||||||
// first figure out which is the last event in the list which we're
|
// 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 the readmarker has moved, cancel any active ghost.
|
||||||
if (this.currentReadMarkerEventId && this.props.readMarkerEventId &&
|
if (this.currentReadMarkerEventId && this.props.readMarkerEventId &&
|
||||||
this.props.readMarkerVisible &&
|
this.props.readMarkerVisible &&
|
||||||
this.currentReadMarkerEventId != this.props.readMarkerEventId) {
|
this.currentReadMarkerEventId !== this.props.readMarkerEventId) {
|
||||||
this.currentGhostEventId = null;
|
this.currentGhostEventId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -404,8 +411,8 @@ module.exports = React.createClass({
|
||||||
|
|
||||||
let isVisibleReadMarker = false;
|
let isVisibleReadMarker = false;
|
||||||
|
|
||||||
if (eventId == this.props.readMarkerEventId) {
|
if (eventId === this.props.readMarkerEventId) {
|
||||||
var visible = this.props.readMarkerVisible;
|
visible = this.props.readMarkerVisible;
|
||||||
|
|
||||||
// if the read marker comes at the end of the timeline (except
|
// if the read marker comes at the end of the timeline (except
|
||||||
// for local echoes, which are excluded from RMs, because they
|
// 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
|
// 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.
|
// 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.
|
// if we're showing an animation, continue to show it.
|
||||||
ret.push(this._getReadMarkerGhostTile());
|
ret.push(this._getReadMarkerGhostTile());
|
||||||
} else if (!isVisibleReadMarker &&
|
} else if (!isVisibleReadMarker &&
|
||||||
eventId == this.currentReadMarkerEventId) {
|
eventId === this.currentReadMarkerEventId) {
|
||||||
// there is currently a read-up-to marker at this point, but no
|
// there is currently a read-up-to marker at this point, but no
|
||||||
// more. Show an animation of it disappearing.
|
// more. Show an animation of it disappearing.
|
||||||
ret.push(this._getReadMarkerGhostTile());
|
ret.push(this._getReadMarkerGhostTile());
|
||||||
|
@ -449,16 +456,17 @@ module.exports = React.createClass({
|
||||||
|
|
||||||
// Some events should appear as continuations from previous events of
|
// Some events should appear as continuations from previous events of
|
||||||
// different types.
|
// different types.
|
||||||
const continuedTypes = ['m.sticker', 'm.room.message'];
|
|
||||||
const eventTypeContinues =
|
const eventTypeContinues =
|
||||||
prevEvent !== null &&
|
prevEvent !== null &&
|
||||||
continuedTypes.includes(mxEv.getType()) &&
|
continuedTypes.includes(mxEv.getType()) &&
|
||||||
continuedTypes.includes(prevEvent.getType());
|
continuedTypes.includes(prevEvent.getType());
|
||||||
|
|
||||||
if (prevEvent !== null
|
// if there is a previous event and it has the same sender as this event
|
||||||
&& prevEvent.sender && mxEv.sender
|
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
|
||||||
&& mxEv.sender.userId === prevEvent.sender.userId
|
if (prevEvent !== null && prevEvent.sender && mxEv.sender && mxEv.sender.userId === prevEvent.sender.userId &&
|
||||||
&& (mxEv.getType() == prevEvent.getType() || eventTypeContinues)) {
|
(mxEv.getType() === prevEvent.getType() || eventTypeContinues) &&
|
||||||
|
(mxEv.getTs() - prevEvent.getTs() <= CONTINUATION_MAX_INTERVAL)) {
|
||||||
continuation = true;
|
continuation = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -493,7 +501,7 @@ module.exports = React.createClass({
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventId = mxEv.getId();
|
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.
|
// we can't use local echoes as scroll tokens, because their event IDs change.
|
||||||
// Local echos have a send "status".
|
// Local echos have a send "status".
|
||||||
|
@ -632,7 +640,8 @@ module.exports = React.createClass({
|
||||||
render: function() {
|
render: function() {
|
||||||
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
|
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
|
||||||
const Spinner = sdk.getComponent("elements.Spinner");
|
const Spinner = sdk.getComponent("elements.Spinner");
|
||||||
let topSpinner, bottomSpinner;
|
let topSpinner;
|
||||||
|
let bottomSpinner;
|
||||||
if (this.props.backPaginating) {
|
if (this.props.backPaginating) {
|
||||||
topSpinner = <li key="_topSpinner"><Spinner /></li>;
|
topSpinner = <li key="_topSpinner"><Spinner /></li>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,7 +70,7 @@ export default withMatrixClient(React.createClass({
|
||||||
if (this.state.groups) {
|
if (this.state.groups) {
|
||||||
const groupNodes = [];
|
const groupNodes = [];
|
||||||
this.state.groups.forEach((g) => {
|
this.state.groups.forEach((g) => {
|
||||||
groupNodes.push(<GroupTile groupId={g} />);
|
groupNodes.push(<GroupTile key={g} groupId={g} />);
|
||||||
});
|
});
|
||||||
contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />;
|
contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />;
|
||||||
content = groupNodes.length > 0 ?
|
content = groupNodes.length > 0 ?
|
||||||
|
@ -124,7 +124,7 @@ export default withMatrixClient(React.createClass({
|
||||||
) }
|
) }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx_MyGroups_joinBox mx_MyGroups_headerCard">
|
{/*<div className="mx_MyGroups_joinBox mx_MyGroups_headerCard">
|
||||||
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onJoinGroupClick}>
|
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onJoinGroupClick}>
|
||||||
<TintableSvg src="img/icons-create-room.svg" width="50" height="50" />
|
<TintableSvg src="img/icons-create-room.svg" width="50" height="50" />
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
|
@ -140,7 +140,7 @@ export default withMatrixClient(React.createClass({
|
||||||
{ 'i': (sub) => <i>{ sub }</i> })
|
{ 'i': (sub) => <i>{ sub }</i> })
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>*/}
|
||||||
</div>
|
</div>
|
||||||
<div className="mx_MyGroups_content">
|
<div className="mx_MyGroups_content">
|
||||||
{ contentHeader }
|
{ contentHeader }
|
||||||
|
|
|
@ -170,7 +170,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
|
||||||
{ profile }
|
{ profile }
|
||||||
</div>
|
</div>
|
||||||
<DialogButtons primaryButton={_t('Start Chatting')}
|
<DialogButtons primaryButton={_t('Start Chatting')}
|
||||||
onPrimaryButtonClick={this.props.onNewDMClick} focus="true" />
|
onPrimaryButtonClick={this.props.onNewDMClick} focus={true} />
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,7 +36,7 @@ function getOrCreateContainer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Greater than that of the ContextualMenu
|
// 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
|
* Class of component that renders its children in a separate ReactDOM virtual tree
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2017, 2018 New Vector Ltd
|
Copyright 2017, 2018 New Vector Ltd
|
||||||
|
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -20,8 +21,9 @@ import { MatrixClient } from 'matrix-js-sdk';
|
||||||
import sdk from '../../../index';
|
import sdk from '../../../index';
|
||||||
import dis from '../../../dispatcher';
|
import dis from '../../../dispatcher';
|
||||||
import AccessibleButton from '../elements/AccessibleButton';
|
import AccessibleButton from '../elements/AccessibleButton';
|
||||||
import * as ContextualMenu from "../../structures/ContextualMenu";
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import MatrixClientPeg from "../../../MatrixClientPeg";
|
||||||
|
import {createMenu} from "../../structures/ContextualMenu";
|
||||||
|
|
||||||
export default React.createClass({
|
export default React.createClass({
|
||||||
displayName: 'GroupInviteTile',
|
displayName: 'GroupInviteTile',
|
||||||
|
@ -66,29 +68,11 @@ export default React.createClass({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
onBadgeClicked: function(e) {
|
_showContextMenu: function(x, y, chevronOffset) {
|
||||||
// Prevent the RoomTile onClick event firing as well
|
const GroupInviteTileContextMenu = sdk.getComponent('context_menus.GroupInviteTileContextMenu');
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// Only allow none guests to access the context menu
|
createMenu(GroupInviteTileContextMenu, {
|
||||||
if (this.context.matrixClient.isGuest()) return;
|
chevronOffset,
|
||||||
|
|
||||||
// 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,
|
|
||||||
left: x,
|
left: x,
|
||||||
top: y,
|
top: y,
|
||||||
group: this.props.group,
|
group: this.props.group,
|
||||||
|
@ -99,6 +83,38 @@ export default React.createClass({
|
||||||
this.setState({ menuDisplayed: true });
|
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() {
|
render: function() {
|
||||||
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
|
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
|
||||||
const EmojiText = sdk.getComponent('elements.EmojiText');
|
const EmojiText = sdk.getComponent('elements.EmojiText');
|
||||||
|
@ -139,7 +155,12 @@ export default React.createClass({
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccessibleButton className={classes} onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
|
<AccessibleButton className={classes}
|
||||||
|
onClick={this.onClick}
|
||||||
|
onMouseEnter={this.onMouseEnter}
|
||||||
|
onMouseLeave={this.onMouseLeave}
|
||||||
|
onContextMenu={this.onContextMenu}
|
||||||
|
>
|
||||||
<div className="mx_RoomTile_avatar">
|
<div className="mx_RoomTile_avatar">
|
||||||
{ av }
|
{ av }
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -69,7 +69,7 @@ export default React.createClass({
|
||||||
render() {
|
render() {
|
||||||
const GroupTile = sdk.getComponent('groups.GroupTile');
|
const GroupTile = sdk.getComponent('groups.GroupTile');
|
||||||
const input = <input type="checkbox"
|
const input = <input type="checkbox"
|
||||||
onClick={this._onPublicityToggle}
|
onChange={this._onPublicityToggle}
|
||||||
checked={this.state.isGroupPublicised}
|
checked={this.state.isGroupPublicised}
|
||||||
/>;
|
/>;
|
||||||
const labelText = !this.state.ready ? _t("Loading...") :
|
const labelText = !this.state.ready ? _t("Loading...") :
|
||||||
|
|
|
@ -22,6 +22,7 @@ import sdk from '../../../index';
|
||||||
import dis from '../../../dispatcher';
|
import dis from '../../../dispatcher';
|
||||||
import FlairStore from '../../../stores/FlairStore';
|
import FlairStore from '../../../stores/FlairStore';
|
||||||
|
|
||||||
|
function nop() {}
|
||||||
|
|
||||||
const GroupTile = React.createClass({
|
const GroupTile = React.createClass({
|
||||||
displayName: 'GroupTile',
|
displayName: 'GroupTile',
|
||||||
|
@ -81,7 +82,7 @@ const GroupTile = React.createClass({
|
||||||
) : null;
|
) : null;
|
||||||
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
|
// 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
|
// instead of onClick. Otherwise we experience https://github.com/vector-im/riot-web/issues/6156
|
||||||
return <AccessibleButton className="mx_GroupTile" onMouseDown={this.onMouseDown}>
|
return <AccessibleButton className="mx_GroupTile" onMouseDown={this.onMouseDown} onClick={nop}>
|
||||||
<Droppable droppableId="my-groups-droppable" type="draggable-TagTile">
|
<Droppable droppableId="my-groups-droppable" type="draggable-TagTile">
|
||||||
{ (droppableProvided, droppableSnapshot) => (
|
{ (droppableProvided, droppableSnapshot) => (
|
||||||
<div ref={droppableProvided.innerRef}>
|
<div ref={droppableProvided.innerRef}>
|
||||||
|
|
|
@ -327,6 +327,7 @@ module.exports = React.createClass({
|
||||||
// will have the correct name when the user tries to download it.
|
// 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.
|
// We can't provide a Content-Disposition header like we would for HTTP.
|
||||||
download: fileName,
|
download: fileName,
|
||||||
|
rel: "noopener",
|
||||||
target: "_blank",
|
target: "_blank",
|
||||||
textContent: _t("Download %(text)s", { text: text }),
|
textContent: _t("Download %(text)s", { text: text }),
|
||||||
}, "*");
|
}, "*");
|
||||||
|
|
|
@ -36,6 +36,7 @@ import * as ContextualMenu from '../../structures/ContextualMenu';
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import PushProcessor from 'matrix-js-sdk/lib/pushprocessor';
|
import PushProcessor from 'matrix-js-sdk/lib/pushprocessor';
|
||||||
import ReplyThread from "../elements/ReplyThread";
|
import ReplyThread from "../elements/ReplyThread";
|
||||||
|
import {host as matrixtoHost} from '../../../matrix-to';
|
||||||
|
|
||||||
linkifyMatrix(linkify);
|
linkifyMatrix(linkify);
|
||||||
|
|
||||||
|
@ -304,7 +305,7 @@ module.exports = React.createClass({
|
||||||
// never preview matrix.to links (if anything we should give a smart
|
// 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
|
// preview of the room/user they point to: nobody needs to be reminded
|
||||||
// what the matrix.to site looks like).
|
// 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())) {
|
if (node.textContent.toLowerCase().trim().startsWith(host.toLowerCase())) {
|
||||||
// it's a "foo.pl" style link
|
// it's a "foo.pl" style link
|
||||||
|
|
|
@ -45,8 +45,7 @@ import Markdown from '../../../Markdown';
|
||||||
import ComposerHistoryManager from '../../../ComposerHistoryManager';
|
import ComposerHistoryManager from '../../../ComposerHistoryManager';
|
||||||
import MessageComposerStore from '../../../stores/MessageComposerStore';
|
import MessageComposerStore from '../../../stores/MessageComposerStore';
|
||||||
|
|
||||||
import {MATRIXTO_URL_PATTERN, MATRIXTO_MD_LINK_PATTERN} from '../../../linkify-matrix';
|
import {MATRIXTO_MD_LINK_PATTERN} from '../../../linkify-matrix';
|
||||||
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
|
|
||||||
const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g');
|
const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g');
|
||||||
|
|
||||||
import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione';
|
import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione';
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2015, 2016 OpenMarket Ltd
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
Copyright 2017 New Vector Ltd
|
Copyright 2017 New Vector Ltd
|
||||||
|
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -15,19 +16,17 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const React = require('react');
|
import React from 'react';
|
||||||
const ReactDOM = require("react-dom");
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
const classNames = require('classnames');
|
import classNames from 'classnames';
|
||||||
import dis from '../../../dispatcher';
|
import dis from '../../../dispatcher';
|
||||||
const MatrixClientPeg = require('../../../MatrixClientPeg');
|
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||||
import DMRoomMap from '../../../utils/DMRoomMap';
|
import DMRoomMap from '../../../utils/DMRoomMap';
|
||||||
const sdk = require('../../../index');
|
import sdk from '../../../index';
|
||||||
const ContextualMenu = require('../../structures/ContextualMenu');
|
import {createMenu} from '../../structures/ContextualMenu';
|
||||||
const RoomNotifs = require('../../../RoomNotifs');
|
import * as RoomNotifs from '../../../RoomNotifs';
|
||||||
const FormattingUtils = require('../../../utils/FormattingUtils');
|
import * as FormattingUtils from '../../../utils/FormattingUtils';
|
||||||
import AccessibleButton from '../elements/AccessibleButton';
|
import AccessibleButton from '../elements/AccessibleButton';
|
||||||
import ActiveRoomObserver from '../../../ActiveRoomObserver';
|
import ActiveRoomObserver from '../../../ActiveRoomObserver';
|
||||||
import RoomViewStore from '../../../stores/RoomViewStore';
|
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||||
|
@ -72,16 +71,12 @@ module.exports = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
_shouldShowMentionBadge: function() {
|
_shouldShowMentionBadge: function() {
|
||||||
return this.state.notifState != RoomNotifs.MUTE;
|
return this.state.notifState !== RoomNotifs.MUTE;
|
||||||
},
|
},
|
||||||
|
|
||||||
_isDirectMessageRoom: function(roomId) {
|
_isDirectMessageRoom: function(roomId) {
|
||||||
const dmRooms = DMRoomMap.shared().getUserIdForRoomId(roomId);
|
const dmRooms = DMRoomMap.shared().getUserIdForRoomId(roomId);
|
||||||
if (dmRooms) {
|
return Boolean(dmRooms);
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onRoomTimeline: function(ev, room) {
|
onRoomTimeline: function(ev, room) {
|
||||||
|
@ -99,7 +94,7 @@ module.exports = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
onAccountData: function(accountDataEvent) {
|
onAccountData: function(accountDataEvent) {
|
||||||
if (accountDataEvent.getType() == 'm.push_rules') {
|
if (accountDataEvent.getType() === 'm.push_rules') {
|
||||||
this.setState({
|
this.setState({
|
||||||
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
|
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
|
||||||
});
|
});
|
||||||
|
@ -187,6 +182,32 @@ module.exports = React.createClass({
|
||||||
this.badgeOnMouseLeave();
|
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() {
|
badgeOnMouseEnter: function() {
|
||||||
// Only allow non-guests to access the context menu
|
// Only allow non-guests to access the context menu
|
||||||
// and only change it if it needs to change
|
// and only change it if it needs to change
|
||||||
|
@ -200,37 +221,25 @@ module.exports = React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
onBadgeClicked: function(e) {
|
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
|
// Prevent the RoomTile onClick event firing as well
|
||||||
e.stopPropagation();
|
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() {
|
render: function() {
|
||||||
|
@ -250,7 +259,7 @@ module.exports = React.createClass({
|
||||||
'mx_RoomTile_unread': this.props.unread,
|
'mx_RoomTile_unread': this.props.unread,
|
||||||
'mx_RoomTile_unreadNotify': notifBadges,
|
'mx_RoomTile_unreadNotify': notifBadges,
|
||||||
'mx_RoomTile_highlight': mentionBadges,
|
'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_menuDisplayed': this.state.menuDisplayed,
|
||||||
'mx_RoomTile_noBadges': !badges,
|
'mx_RoomTile_noBadges': !badges,
|
||||||
'mx_RoomTile_transparent': this.props.transparent,
|
'mx_RoomTile_transparent': this.props.transparent,
|
||||||
|
@ -268,7 +277,6 @@ module.exports = React.createClass({
|
||||||
let name = this.state.roomName;
|
let name = this.state.roomName;
|
||||||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||||
|
|
||||||
let badge;
|
|
||||||
let badgeContent;
|
let badgeContent;
|
||||||
|
|
||||||
if (this.state.badgeHover || this.state.menuDisplayed) {
|
if (this.state.badgeHover || this.state.menuDisplayed) {
|
||||||
|
@ -280,7 +288,7 @@ module.exports = React.createClass({
|
||||||
badgeContent = '\u200B';
|
badgeContent = '\u200B';
|
||||||
}
|
}
|
||||||
|
|
||||||
badge = <div className={badgeClasses} onClick={this.onBadgeClicked}>{ badgeContent }</div>;
|
const badge = <div className={badgeClasses} onClick={this.onBadgeClicked}>{ badgeContent }</div>;
|
||||||
|
|
||||||
const EmojiText = sdk.getComponent('elements.EmojiText');
|
const EmojiText = sdk.getComponent('elements.EmojiText');
|
||||||
let label;
|
let label;
|
||||||
|
@ -312,16 +320,22 @@ module.exports = React.createClass({
|
||||||
|
|
||||||
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
|
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
|
||||||
|
|
||||||
let directMessageIndicator;
|
let dmIndicator;
|
||||||
if (this._isDirectMessageRoom(this.props.room.roomId)) {
|
if (this._isDirectMessageRoom(this.props.room.roomId)) {
|
||||||
directMessageIndicator = <img src="img/icon_person.svg" className="mx_RoomTile_dm" width="11" height="13" alt="dm" />;
|
dmIndicator = <img src="img/icon_person.svg" className="mx_RoomTile_dm" width="11" height="13" alt="dm" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AccessibleButton className={classes} tabIndex="0" onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
|
return <AccessibleButton tabIndex="0"
|
||||||
|
className={classes}
|
||||||
|
onClick={this.onClick}
|
||||||
|
onMouseEnter={this.onMouseEnter}
|
||||||
|
onMouseLeave={this.onMouseLeave}
|
||||||
|
onContextMenu={this.onContextMenu}
|
||||||
|
>
|
||||||
<div className={avatarClasses}>
|
<div className={avatarClasses}>
|
||||||
<div className="mx_RoomTile_avatar_container">
|
<div className="mx_RoomTile_avatar_container">
|
||||||
<RoomAvatar room={this.props.room} width={24} height={24} />
|
<RoomAvatar room={this.props.room} width={24} height={24} />
|
||||||
{ directMessageIndicator }
|
{ dmIndicator }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx_RoomTile_nameContainer">
|
<div className="mx_RoomTile_nameContainer">
|
||||||
|
|
|
@ -169,11 +169,18 @@ matrixLinkify.VECTOR_URL_PATTERN = "^(?:https?:\/\/)?(?:"
|
||||||
+ "(?:www\\.)?(?:riot|vector)\\.im/(?:app|beta|staging|develop)/"
|
+ "(?: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 =
|
matrixLinkify.MATRIXTO_MD_LINK_PATTERN =
|
||||||
'\\[([^\\]]*)\\]\\((?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/((#|@|!)[^\\)]*)\\)';
|
'\\[([^\\]]*)\\]\\((?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/([#@!+][^\\)]*)\\)';
|
||||||
matrixLinkify.MATRIXTO_BASE_URL= baseUrl;
|
matrixLinkify.MATRIXTO_BASE_URL= baseUrl;
|
||||||
|
|
||||||
|
const matrixToEntityMap = {
|
||||||
|
'@': '#/user/',
|
||||||
|
'#': '#/room/',
|
||||||
|
'!': '#/room/',
|
||||||
|
'+': '#/group/',
|
||||||
|
};
|
||||||
|
|
||||||
matrixLinkify.options = {
|
matrixLinkify.options = {
|
||||||
events: function(href, type) {
|
events: function(href, type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
@ -204,24 +211,20 @@ matrixLinkify.options = {
|
||||||
case 'userid':
|
case 'userid':
|
||||||
case 'groupid':
|
case 'groupid':
|
||||||
return matrixLinkify.MATRIXTO_BASE_URL + '/#/' + href;
|
return matrixLinkify.MATRIXTO_BASE_URL + '/#/' + href;
|
||||||
default:
|
default: {
|
||||||
var m;
|
|
||||||
// FIXME: horrible duplication with HtmlUtils' transform tags
|
// FIXME: horrible duplication with HtmlUtils' transform tags
|
||||||
m = href.match(matrixLinkify.VECTOR_URL_PATTERN);
|
let m = href.match(matrixLinkify.VECTOR_URL_PATTERN);
|
||||||
if (m) {
|
if (m) {
|
||||||
return m[1];
|
return m[1];
|
||||||
}
|
}
|
||||||
m = href.match(matrixLinkify.MATRIXTO_URL_PATTERN);
|
m = href.match(matrixLinkify.MATRIXTO_URL_PATTERN);
|
||||||
if (m) {
|
if (m) {
|
||||||
const entity = m[1];
|
const entity = m[1];
|
||||||
if (entity[0] === '@') {
|
if (matrixToEntityMap[entity[0]]) return matrixToEntityMap[entity[0]] + entity;
|
||||||
return '#/user/' + entity;
|
|
||||||
} else if (entity[0] === '#' || entity[0] === '!') {
|
|
||||||
return '#/room/' + entity;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return href;
|
return href;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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) {
|
export function makeEventPermalink(roomId, eventId) {
|
||||||
return `${baseUrl}/#/${roomId}/${eventId}`;
|
return `${baseUrl}/#/${roomId}/${eventId}`;
|
||||||
|
|
185
test/DecryptionFailureTracker-test.js
Normal file
185
test/DecryptionFailureTracker-test.js
Normal file
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue