Merge remote-tracking branch 'origin/develop' into dbkr/bootstrap_visuals_1
This commit is contained in:
commit
10db79bb93
39 changed files with 950 additions and 311 deletions
|
@ -76,6 +76,11 @@ steps:
|
||||||
- docker#v3.0.1:
|
- docker#v3.0.1:
|
||||||
image: "matrixdotorg/riotweb-ci-e2etests-env:latest"
|
image: "matrixdotorg/riotweb-ci-e2etests-env:latest"
|
||||||
propagate-environment: true
|
propagate-environment: true
|
||||||
|
workdir: "/workdir/matrix-react-sdk"
|
||||||
|
retry:
|
||||||
|
automatic:
|
||||||
|
- exit_status: 1 # retry end-to-end tests once as Puppeteer sometimes fails
|
||||||
|
limit: 1
|
||||||
|
|
||||||
- label: "🔧 Riot Tests"
|
- label: "🔧 Riot Tests"
|
||||||
agents:
|
agents:
|
||||||
|
@ -83,27 +88,16 @@ steps:
|
||||||
# webpack loves to gorge itself on resources.
|
# webpack loves to gorge itself on resources.
|
||||||
queue: "medium"
|
queue: "medium"
|
||||||
command:
|
command:
|
||||||
# Install chrome
|
|
||||||
- "echo '--- Installing Chrome'"
|
|
||||||
- "wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -"
|
|
||||||
- "sh -c 'echo \"deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main\" >> /etc/apt/sources.list.d/google.list'"
|
|
||||||
- "apt-get update"
|
|
||||||
- "apt-get install -y google-chrome-stable"
|
|
||||||
# TODO: Remove hacky chmod for BuildKite
|
# TODO: Remove hacky chmod for BuildKite
|
||||||
- "chmod +x ./scripts/ci/*.sh"
|
- "chmod +x ./scripts/ci/*.sh"
|
||||||
- "chmod +x ./scripts/*"
|
- "chmod +x ./scripts/*"
|
||||||
- "echo '--- Installing Dependencies'"
|
|
||||||
- "./scripts/ci/install-deps.sh"
|
|
||||||
- "echo '--- Running initial build steps'"
|
|
||||||
- "yarn build"
|
|
||||||
- "echo '+++ Running Tests'"
|
- "echo '+++ Running Tests'"
|
||||||
- "./scripts/ci/riot-unit-tests.sh"
|
- "./scripts/ci/riot-unit-tests.sh"
|
||||||
env:
|
|
||||||
CHROME_BIN: "/usr/bin/google-chrome-stable"
|
|
||||||
plugins:
|
plugins:
|
||||||
- docker#v3.0.1:
|
- docker#v3.0.1:
|
||||||
image: "node:10"
|
image: "node:10"
|
||||||
propagate-environment: true
|
propagate-environment: true
|
||||||
|
workdir: "/workdir/matrix-react-sdk"
|
||||||
|
|
||||||
- label: "🌐 i18n"
|
- label: "🌐 i18n"
|
||||||
command:
|
command:
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
"typings": "./lib/index.d.ts",
|
"typings": "./lib/index.d.ts",
|
||||||
"matrix_src_main": "./src/index.js",
|
"matrix_src_main": "./src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepublish": "yarn build",
|
"prepare": "yarn build",
|
||||||
"i18n": "matrix-gen-i18n",
|
"i18n": "matrix-gen-i18n",
|
||||||
"prunei18n": "matrix-prune-i18n",
|
"prunei18n": "matrix-prune-i18n",
|
||||||
"diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json",
|
"diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json",
|
||||||
|
|
|
@ -63,7 +63,7 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_GroupHeader_editButton::before {
|
.mx_GroupHeader_editButton::before {
|
||||||
mask-image: url('$(res)/img/icons-settings-room.svg');
|
mask-image: url('$(res)/img/feather-customised/settings.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_GroupHeader_shareButton::before {
|
.mx_GroupHeader_shareButton::before {
|
||||||
|
|
|
@ -51,8 +51,8 @@ limitations under the License.
|
||||||
&.mx_Toast_hasIcon {
|
&.mx_Toast_hasIcon {
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: "";
|
||||||
width: 21px;
|
width: 22px;
|
||||||
height: 20px;
|
height: 22px;
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
mask-size: 100%;
|
mask-size: 100%;
|
||||||
|
|
|
@ -367,6 +367,11 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_EventTile_e2eIcon_unknown {
|
||||||
|
background-image: url('$(res)/img/e2e/warning.svg');
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_EventTile_e2eIcon_unencrypted {
|
.mx_EventTile_e2eIcon_unencrypted {
|
||||||
background-image: url('$(res)/img/e2e/warning.svg');
|
background-image: url('$(res)/img/e2e/warning.svg');
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
@ -415,7 +420,8 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line,
|
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line,
|
||||||
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line {
|
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line,
|
||||||
|
.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line {
|
||||||
padding-left: 60px;
|
padding-left: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -427,8 +433,13 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
|
||||||
border-left: $e2e-unverified-color 5px solid;
|
border-left: $e2e-unverified-color 5px solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line {
|
||||||
|
border-left: $e2e-unknown-color 5px solid;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_EventTile:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line,
|
.mx_EventTile:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line,
|
||||||
.mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line {
|
.mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line,
|
||||||
|
.mx_EventTile:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line {
|
||||||
padding-left: 78px;
|
padding-left: 78px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -439,14 +450,16 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
|
||||||
|
|
||||||
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
|
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
|
||||||
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp,
|
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp,
|
||||||
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp {
|
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp,
|
||||||
|
.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp {
|
||||||
left: 3px;
|
left: 3px;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
|
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
|
||||||
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > .mx_EventTile_e2eIcon,
|
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > .mx_EventTile_e2eIcon,
|
||||||
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon {
|
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon,
|
||||||
|
.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > .mx_EventTile_e2eIcon {
|
||||||
display: block;
|
display: block;
|
||||||
left: 41px;
|
left: 41px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,10 @@ limitations under the License.
|
||||||
border-bottom: 1px solid $primary-hairline-color;
|
border-bottom: 1px solid $primary-hairline-color;
|
||||||
|
|
||||||
.mx_E2EIcon {
|
.mx_E2EIcon {
|
||||||
margin: 0 5px;
|
margin: 0;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: -5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,6 +174,7 @@ limitations under the License.
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
margin: 0 7px;
|
margin: 0 7px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_RoomHeader_avatar .mx_BaseAvatar_image {
|
.mx_RoomHeader_avatar .mx_BaseAvatar_image {
|
||||||
|
|
|
@ -142,10 +142,11 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// toggle menuButton and badge on hover/menu displayed
|
// toggle menuButton and badge on menu displayed
|
||||||
.mx_RoomTile_menuDisplayed,
|
.mx_RoomTile_menuDisplayed,
|
||||||
// or on keyboard focus of room tile
|
// or on keyboard focus of room tile
|
||||||
.mx_RoomTile.focus-visible:focus-within,
|
.mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:focus-within,
|
||||||
|
// or on pointer hover
|
||||||
.mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:hover {
|
.mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:hover {
|
||||||
.mx_RoomTile_menuButton {
|
.mx_RoomTile_menuButton {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 23 KiB |
|
@ -224,6 +224,7 @@ $copy-button-url: "$(res)/img/icon_copy_message.svg";
|
||||||
|
|
||||||
// e2e
|
// e2e
|
||||||
$e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color
|
$e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color
|
||||||
|
$e2e-unknown-color: #e8bf37;
|
||||||
$e2e-unverified-color: #e8bf37;
|
$e2e-unverified-color: #e8bf37;
|
||||||
$e2e-warning-color: #ba6363;
|
$e2e-warning-color: #ba6363;
|
||||||
|
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# script which is run by the CI build (after `yarn test`).
|
|
||||||
#
|
|
||||||
# clones riot-web develop and runs the tests against our version of react-sdk.
|
|
||||||
|
|
||||||
set -ev
|
|
||||||
|
|
||||||
RIOT_WEB_DIR=riot-web
|
|
||||||
REACT_SDK_DIR=`pwd`
|
|
||||||
|
|
||||||
yarn link
|
|
||||||
|
|
||||||
scripts/fetchdep.sh vector-im riot-web
|
|
||||||
|
|
||||||
pushd "$RIOT_WEB_DIR"
|
|
||||||
|
|
||||||
yarn link matrix-js-sdk
|
|
||||||
yarn link matrix-react-sdk
|
|
||||||
|
|
||||||
yarn install
|
|
||||||
|
|
||||||
yarn build
|
|
||||||
|
|
||||||
popd
|
|
|
@ -21,15 +21,16 @@ handle_error() {
|
||||||
|
|
||||||
trap 'handle_error' ERR
|
trap 'handle_error' ERR
|
||||||
|
|
||||||
RIOT_WEB_DIR=riot-web
|
|
||||||
REACT_SDK_DIR=`pwd`
|
|
||||||
|
|
||||||
|
|
||||||
echo "--- Building Riot"
|
echo "--- Building Riot"
|
||||||
scripts/ci/build.sh
|
scripts/ci/layered-riot-web.sh
|
||||||
|
cd ../riot-web
|
||||||
|
riot_web_dir=`pwd`
|
||||||
|
CI_PACKAGE=true yarn build
|
||||||
|
cd ../matrix-react-sdk
|
||||||
# run end to end tests
|
# run end to end tests
|
||||||
pushd test/end-to-end-tests
|
pushd test/end-to-end-tests
|
||||||
ln -s $REACT_SDK_DIR/$RIOT_WEB_DIR riot/riot-web
|
ln -s $riot_web_dir riot/riot-web
|
||||||
# PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh
|
# PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh
|
||||||
# CHROME_PATH=$(which google-chrome-stable) ./run.sh
|
# CHROME_PATH=$(which google-chrome-stable) ./run.sh
|
||||||
echo "--- Install synapse & other dependencies"
|
echo "--- Install synapse & other dependencies"
|
||||||
|
|
31
scripts/ci/layered-riot-web.sh
Normal file
31
scripts/ci/layered-riot-web.sh
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Creates an environment similar to one that riot-web would expect for
|
||||||
|
# development. This means going one directory up (and assuming we're in
|
||||||
|
# a directory like /workdir/matrix-react-sdk) and putting riot-web and
|
||||||
|
# the js-sdk there.
|
||||||
|
|
||||||
|
cd ../ # Assume we're at something like /workdir/matrix-react-sdk
|
||||||
|
|
||||||
|
# Set up the js-sdk first
|
||||||
|
matrix-react-sdk/scripts/fetchdep.sh matrix-org matrix-js-sdk
|
||||||
|
pushd matrix-js-sdk
|
||||||
|
yarn link
|
||||||
|
yarn install
|
||||||
|
popd
|
||||||
|
|
||||||
|
# Now set up the react-sdk
|
||||||
|
pushd matrix-react-sdk
|
||||||
|
yarn link matrix-js-sdk
|
||||||
|
yarn link
|
||||||
|
yarn install
|
||||||
|
popd
|
||||||
|
|
||||||
|
# Finally, set up riot-web
|
||||||
|
matrix-react-sdk/scripts/fetchdep.sh vector-im riot-web
|
||||||
|
pushd riot-web
|
||||||
|
yarn link matrix-js-sdk
|
||||||
|
yarn link matrix-react-sdk
|
||||||
|
yarn install
|
||||||
|
yarn build:res
|
||||||
|
popd
|
|
@ -6,9 +6,7 @@
|
||||||
|
|
||||||
set -ev
|
set -ev
|
||||||
|
|
||||||
RIOT_WEB_DIR=riot-web
|
scripts/ci/layered-riot-web.sh
|
||||||
|
cd ../riot-web
|
||||||
scripts/ci/build.sh
|
yarn build:genfiles # so the tests can run. Faster version of `build`
|
||||||
pushd "$RIOT_WEB_DIR"
|
|
||||||
yarn test
|
yarn test
|
||||||
popd
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ clone() {
|
||||||
if [ -n "$branch" ]
|
if [ -n "$branch" ]
|
||||||
then
|
then
|
||||||
echo "Trying to use $org/$repo#$branch"
|
echo "Trying to use $org/$repo#$branch"
|
||||||
git clone git://github.com/$org/$repo.git $repo --branch "$branch" && exit 0
|
git clone git://github.com/$org/$repo.git $repo --branch "$branch" --depth 1 && exit 0
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -75,7 +75,7 @@ export default class DeviceListener {
|
||||||
if (device.deviceId == cli.deviceId) continue;
|
if (device.deviceId == cli.deviceId) continue;
|
||||||
|
|
||||||
const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId);
|
const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId);
|
||||||
if (deviceTrust.isVerified() || this._dismissed.has(device.deviceId)) {
|
if (deviceTrust.isCrossSigningVerified() || this._dismissed.has(device.deviceId)) {
|
||||||
ToastStore.sharedInstance().dismissToast(toastKey(device));
|
ToastStore.sharedInstance().dismissToast(toastKey(device));
|
||||||
} else {
|
} else {
|
||||||
ToastStore.sharedInstance().addOrReplaceToast({
|
ToastStore.sharedInstance().addOrReplaceToast({
|
||||||
|
|
|
@ -91,7 +91,7 @@ export default class Markdown {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
toHTML() {
|
toHTML({ externalLinks = false } = {}) {
|
||||||
const renderer = new commonmark.HtmlRenderer({
|
const renderer = new commonmark.HtmlRenderer({
|
||||||
safe: false,
|
safe: false,
|
||||||
|
|
||||||
|
@ -125,6 +125,24 @@ export default class Markdown {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
renderer.link = function(node, entering) {
|
||||||
|
const attrs = this.attrs(node);
|
||||||
|
if (entering) {
|
||||||
|
attrs.push(['href', this.esc(node.destination)]);
|
||||||
|
if (node.title) {
|
||||||
|
attrs.push(['title', this.esc(node.title)]);
|
||||||
|
}
|
||||||
|
// Modified link behaviour to treat them all as external and
|
||||||
|
// thus opening in a new tab.
|
||||||
|
if (externalLinks) {
|
||||||
|
attrs.push(['target', '_blank']);
|
||||||
|
attrs.push(['rel', 'noopener']);
|
||||||
|
}
|
||||||
|
this.tag('a', attrs);
|
||||||
|
} else {
|
||||||
|
this.tag('/a');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
renderer.html_inline = html_if_tag_allowed;
|
renderer.html_inline = html_if_tag_allowed;
|
||||||
|
|
||||||
|
|
|
@ -81,6 +81,8 @@ class Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
run(roomId, args) {
|
run(roomId, args) {
|
||||||
|
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
|
||||||
|
if (!this.runFn) return;
|
||||||
return this.runFn.bind(this)(roomId, args);
|
return this.runFn.bind(this)(roomId, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -905,25 +907,25 @@ const aliases = {
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process the given text for /commands and perform them.
|
* Process the given text for /commands and return a bound method to perform them.
|
||||||
* @param {string} roomId The room in which the command was performed.
|
* @param {string} roomId The room in which the command was performed.
|
||||||
* @param {string} input The raw text input by the user.
|
* @param {string} input The raw text input by the user.
|
||||||
* @return {Object|null} An object with the property 'error' if there was an error
|
* @return {null|function(): Object} Function returning an object with the property 'error' if there was an error
|
||||||
* processing the command, or 'promise' if a request was sent out.
|
* processing the command, or 'promise' if a request was sent out.
|
||||||
* Returns null if the input didn't match a command.
|
* Returns null if the input didn't match a command.
|
||||||
*/
|
*/
|
||||||
export function processCommandInput(roomId, input) {
|
export function getCommand(roomId, input) {
|
||||||
// trim any trailing whitespace, as it can confuse the parser for
|
// trim any trailing whitespace, as it can confuse the parser for
|
||||||
// IRC-style commands
|
// IRC-style commands
|
||||||
input = input.replace(/\s+$/, '');
|
input = input.replace(/\s+$/, '');
|
||||||
if (input[0] !== '/') return null; // not a command
|
if (input[0] !== '/') return null; // not a command
|
||||||
|
|
||||||
const bits = input.match(/^(\S+?)( +((.|\n)*))?$/);
|
const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/);
|
||||||
let cmd;
|
let cmd;
|
||||||
let args;
|
let args;
|
||||||
if (bits) {
|
if (bits) {
|
||||||
cmd = bits[1].substring(1).toLowerCase();
|
cmd = bits[1].substring(1).toLowerCase();
|
||||||
args = bits[3];
|
args = bits[2];
|
||||||
} else {
|
} else {
|
||||||
cmd = input;
|
cmd = input;
|
||||||
}
|
}
|
||||||
|
@ -932,11 +934,6 @@ export function processCommandInput(roomId, input) {
|
||||||
cmd = aliases[cmd];
|
cmd = aliases[cmd];
|
||||||
}
|
}
|
||||||
if (CommandMap[cmd]) {
|
if (CommandMap[cmd]) {
|
||||||
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
|
return () => CommandMap[cmd].run(roomId, args);
|
||||||
if (!CommandMap[cmd].runFn) return null;
|
|
||||||
|
|
||||||
return CommandMap[cmd].run(roomId, args);
|
|
||||||
} else {
|
|
||||||
return reject(_t('Unrecognised command:') + ' ' + input);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
224
src/accessibility/RovingTabIndex.js
Normal file
224
src/accessibility/RovingTabIndex.js
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useReducer,
|
||||||
|
} from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import {Key} from "../Keyboard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module to simplify implementing the Roving TabIndex accessibility technique
|
||||||
|
*
|
||||||
|
* Wrap the Widget in an RovingTabIndexContextProvider
|
||||||
|
* and then for all buttons make use of useRovingTabIndex or RovingTabIndexWrapper.
|
||||||
|
* The code will keep track of which tabIndex was most recently focused and expose that information as `isActive` which
|
||||||
|
* can then be used to only set the tabIndex to 0 as expected by the roving tabindex technique.
|
||||||
|
* When the active button gets unmounted the closest button will be chosen as expected.
|
||||||
|
* Initially the first button to mount will be given active state.
|
||||||
|
*
|
||||||
|
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DOCUMENT_POSITION_PRECEDING = 2;
|
||||||
|
|
||||||
|
const RovingTabIndexContext = createContext({
|
||||||
|
state: {
|
||||||
|
activeRef: null,
|
||||||
|
refs: [], // list of refs in DOM order
|
||||||
|
},
|
||||||
|
dispatch: () => {},
|
||||||
|
});
|
||||||
|
RovingTabIndexContext.displayName = "RovingTabIndexContext";
|
||||||
|
|
||||||
|
// TODO use a TypeScript type here
|
||||||
|
const types = {
|
||||||
|
REGISTER: "REGISTER",
|
||||||
|
UNREGISTER: "UNREGISTER",
|
||||||
|
SET_FOCUS: "SET_FOCUS",
|
||||||
|
};
|
||||||
|
|
||||||
|
const reducer = (state, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case types.REGISTER: {
|
||||||
|
if (state.refs.length === 0) {
|
||||||
|
// Our list of refs was empty, set activeRef to this first item
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
activeRef: action.payload.ref,
|
||||||
|
refs: [action.payload.ref],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.refs.includes(action.payload.ref)) {
|
||||||
|
return state; // already in refs, this should not happen
|
||||||
|
}
|
||||||
|
|
||||||
|
// find the index of the first ref which is not preceding this one in DOM order
|
||||||
|
let newIndex = state.refs.findIndex(ref => {
|
||||||
|
return ref.current.compareDocumentPosition(action.payload.ref.current) & DOCUMENT_POSITION_PRECEDING;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newIndex < 0) {
|
||||||
|
newIndex = state.refs.length; // append to the end
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the refs list
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
refs: [
|
||||||
|
...state.refs.slice(0, newIndex),
|
||||||
|
action.payload.ref,
|
||||||
|
...state.refs.slice(newIndex),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case types.UNREGISTER: {
|
||||||
|
// filter out the ref which we are removing
|
||||||
|
const refs = state.refs.filter(r => r !== action.payload.ref);
|
||||||
|
|
||||||
|
if (refs.length === state.refs.length) {
|
||||||
|
return state; // already removed, this should not happen
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.activeRef === action.payload.ref) {
|
||||||
|
// we just removed the active ref, need to replace it
|
||||||
|
// pick the ref which is now in the index the old ref was in
|
||||||
|
const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
activeRef: oldIndex >= refs.length ? refs[refs.length - 1] : refs[oldIndex],
|
||||||
|
refs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the refs list
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
refs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case types.SET_FOCUS: {
|
||||||
|
// update active ref
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
activeRef: action.payload.ref,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) => {
|
||||||
|
const [state, dispatch] = useReducer(reducer, {
|
||||||
|
activeRef: null,
|
||||||
|
refs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = useMemo(() => ({state, dispatch}), [state]);
|
||||||
|
|
||||||
|
const onKeyDownHandler = useCallback((ev) => {
|
||||||
|
let handled = false;
|
||||||
|
if (handleHomeEnd) {
|
||||||
|
// check if we actually have any items
|
||||||
|
switch (ev.key) {
|
||||||
|
case Key.HOME:
|
||||||
|
handled = true;
|
||||||
|
// move focus to first item
|
||||||
|
if (context.state.refs.length > 0) {
|
||||||
|
context.state.refs[0].current.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Key.END:
|
||||||
|
handled = true;
|
||||||
|
// move focus to last item
|
||||||
|
if (context.state.refs.length > 0) {
|
||||||
|
context.state.refs[context.state.refs.length - 1].current.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
} else if (onKeyDown) {
|
||||||
|
return onKeyDown(ev);
|
||||||
|
}
|
||||||
|
}, [context.state, onKeyDown, handleHomeEnd]);
|
||||||
|
|
||||||
|
return <RovingTabIndexContext.Provider value={context}>
|
||||||
|
{ children({onKeyDownHandler}) }
|
||||||
|
</RovingTabIndexContext.Provider>;
|
||||||
|
};
|
||||||
|
RovingTabIndexProvider.propTypes = {
|
||||||
|
handleHomeEnd: PropTypes.bool,
|
||||||
|
onKeyDown: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook to register a roving tab index
|
||||||
|
// inputRef parameter specifies the ref to use
|
||||||
|
// onFocus should be called when the index gained focus in any manner
|
||||||
|
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
|
||||||
|
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
|
||||||
|
export const useRovingTabIndex = (inputRef) => {
|
||||||
|
const context = useContext(RovingTabIndexContext);
|
||||||
|
let ref = useRef(null);
|
||||||
|
|
||||||
|
if (inputRef) {
|
||||||
|
// if we are given a ref, use it instead of ours
|
||||||
|
ref = inputRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup (after refs)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
context.dispatch({
|
||||||
|
type: types.REGISTER,
|
||||||
|
payload: {ref},
|
||||||
|
});
|
||||||
|
// teardown
|
||||||
|
return () => {
|
||||||
|
context.dispatch({
|
||||||
|
type: types.UNREGISTER,
|
||||||
|
payload: {ref},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const onFocus = useCallback(() => {
|
||||||
|
context.dispatch({
|
||||||
|
type: types.SET_FOCUS,
|
||||||
|
payload: {ref},
|
||||||
|
});
|
||||||
|
}, [ref, context]);
|
||||||
|
|
||||||
|
const isActive = context.state.activeRef === ref;
|
||||||
|
return [onFocus, isActive, ref];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
|
||||||
|
export const RovingTabIndexWrapper = ({children, inputRef}) => {
|
||||||
|
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||||
|
return children({onFocus, isActive, ref});
|
||||||
|
};
|
||||||
|
|
|
@ -129,9 +129,6 @@ const LeftPanel = createReactClass({
|
||||||
if (!this.focusedElement) return;
|
if (!this.focusedElement) return;
|
||||||
|
|
||||||
switch (ev.key) {
|
switch (ev.key) {
|
||||||
case Key.TAB:
|
|
||||||
this._onMoveFocus(ev, ev.shiftKey);
|
|
||||||
break;
|
|
||||||
case Key.ARROW_UP:
|
case Key.ARROW_UP:
|
||||||
this._onMoveFocus(ev, true, true);
|
this._onMoveFocus(ev, true, true);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -31,6 +31,7 @@ import PropTypes from 'prop-types';
|
||||||
import RoomTile from "../views/rooms/RoomTile";
|
import RoomTile from "../views/rooms/RoomTile";
|
||||||
import LazyRenderList from "../views/elements/LazyRenderList";
|
import LazyRenderList from "../views/elements/LazyRenderList";
|
||||||
import {_t} from "../../languageHandler";
|
import {_t} from "../../languageHandler";
|
||||||
|
import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
// turn this on for drop & drag console debugging galore
|
// turn this on for drop & drag console debugging galore
|
||||||
const debug = false;
|
const debug = false;
|
||||||
|
@ -141,10 +142,6 @@ export default class RoomSubList extends React.PureComponent {
|
||||||
|
|
||||||
onHeaderKeyDown = (ev) => {
|
onHeaderKeyDown = (ev) => {
|
||||||
switch (ev.key) {
|
switch (ev.key) {
|
||||||
case Key.TAB:
|
|
||||||
// Prevent LeftPanel handling Tab if focus is on the sublist header itself
|
|
||||||
ev.stopPropagation();
|
|
||||||
break;
|
|
||||||
case Key.ARROW_LEFT:
|
case Key.ARROW_LEFT:
|
||||||
// On ARROW_LEFT collapse the room sublist
|
// On ARROW_LEFT collapse the room sublist
|
||||||
if (!this.state.hidden && !this.props.forceExpand) {
|
if (!this.state.hidden && !this.props.forceExpand) {
|
||||||
|
@ -263,33 +260,6 @@ export default class RoomSubList extends React.PureComponent {
|
||||||
const subListNotifCount = subListNotifications.count;
|
const subListNotifCount = subListNotifications.count;
|
||||||
const subListNotifHighlight = subListNotifications.highlight;
|
const subListNotifHighlight = subListNotifications.highlight;
|
||||||
|
|
||||||
let badge;
|
|
||||||
if (!this.props.collapsed) {
|
|
||||||
const badgeClasses = classNames({
|
|
||||||
'mx_RoomSubList_badge': true,
|
|
||||||
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
|
|
||||||
});
|
|
||||||
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
|
|
||||||
if (subListNotifCount > 0) {
|
|
||||||
badge = (
|
|
||||||
<AccessibleButton className={badgeClasses} onClick={this._onNotifBadgeClick} aria-label={_t("Jump to first unread room.")}>
|
|
||||||
<div>
|
|
||||||
{ FormattingUtils.formatCount(subListNotifCount) }
|
|
||||||
</div>
|
|
||||||
</AccessibleButton>
|
|
||||||
);
|
|
||||||
} else if (this.props.isInvite && this.props.list.length) {
|
|
||||||
// no notifications but highlight anyway because this is an invite badge
|
|
||||||
badge = (
|
|
||||||
<AccessibleButton className={badgeClasses} onClick={this._onInviteBadgeClick} aria-label={_t("Jump to first invite.")}>
|
|
||||||
<div>
|
|
||||||
{ this.props.list.length }
|
|
||||||
</div>
|
|
||||||
</AccessibleButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When collapsed, allow a long hover on the header to show user
|
// When collapsed, allow a long hover on the header to show user
|
||||||
// the full tag name and room count
|
// the full tag name and room count
|
||||||
let title;
|
let title;
|
||||||
|
@ -305,17 +275,6 @@ export default class RoomSubList extends React.PureComponent {
|
||||||
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
|
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let addRoomButton;
|
|
||||||
if (this.props.onAddRoom) {
|
|
||||||
addRoomButton = (
|
|
||||||
<AccessibleTooltipButton
|
|
||||||
onClick={this.onAddRoom}
|
|
||||||
className="mx_RoomSubList_addRoom"
|
|
||||||
title={this.props.addRoomLabel || _t("Add room")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const len = this.props.list.length + this.props.extraTiles.length;
|
const len = this.props.list.length + this.props.extraTiles.length;
|
||||||
let chevron;
|
let chevron;
|
||||||
if (len) {
|
if (len) {
|
||||||
|
@ -327,25 +286,81 @@ export default class RoomSubList extends React.PureComponent {
|
||||||
chevron = (<div className={chevronClasses} />);
|
chevron = (<div className={chevronClasses} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <RovingTabIndexWrapper inputRef={this._headerButton}>
|
||||||
<div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}>
|
{({onFocus, isActive, ref}) => {
|
||||||
<AccessibleButton
|
const tabIndex = isActive ? 0 : -1;
|
||||||
onClick={this.onClick}
|
|
||||||
className="mx_RoomSubList_label"
|
let badge;
|
||||||
tabIndex={0}
|
if (!this.props.collapsed) {
|
||||||
aria-expanded={!isCollapsed}
|
const badgeClasses = classNames({
|
||||||
inputRef={this._headerButton}
|
'mx_RoomSubList_badge': true,
|
||||||
role="treeitem"
|
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
|
||||||
aria-level="1"
|
});
|
||||||
>
|
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
|
||||||
{ chevron }
|
if (subListNotifCount > 0) {
|
||||||
<span>{this.props.label}</span>
|
badge = (
|
||||||
{ incomingCall }
|
<AccessibleButton
|
||||||
</AccessibleButton>
|
tabIndex={tabIndex}
|
||||||
{ badge }
|
className={badgeClasses}
|
||||||
{ addRoomButton }
|
onClick={this._onNotifBadgeClick}
|
||||||
</div>
|
aria-label={_t("Jump to first unread room.")}
|
||||||
);
|
>
|
||||||
|
<div>
|
||||||
|
{ FormattingUtils.formatCount(subListNotifCount) }
|
||||||
|
</div>
|
||||||
|
</AccessibleButton>
|
||||||
|
);
|
||||||
|
} else if (this.props.isInvite && this.props.list.length) {
|
||||||
|
// no notifications but highlight anyway because this is an invite badge
|
||||||
|
badge = (
|
||||||
|
<AccessibleButton
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
className={badgeClasses}
|
||||||
|
onClick={this._onInviteBadgeClick}
|
||||||
|
aria-label={_t("Jump to first invite.")}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{ this.props.list.length }
|
||||||
|
</div>
|
||||||
|
</AccessibleButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let addRoomButton;
|
||||||
|
if (this.props.onAddRoom) {
|
||||||
|
addRoomButton = (
|
||||||
|
<AccessibleTooltipButton
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
onClick={this.onAddRoom}
|
||||||
|
className="mx_RoomSubList_addRoom"
|
||||||
|
title={this.props.addRoomLabel || _t("Add room")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}>
|
||||||
|
<AccessibleButton
|
||||||
|
onFocus={onFocus}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
inputRef={ref}
|
||||||
|
onClick={this.onClick}
|
||||||
|
className="mx_RoomSubList_label"
|
||||||
|
aria-expanded={!isCollapsed}
|
||||||
|
role="treeitem"
|
||||||
|
aria-level="1"
|
||||||
|
>
|
||||||
|
{ chevron }
|
||||||
|
<span>{this.props.label}</span>
|
||||||
|
{ incomingCall }
|
||||||
|
</AccessibleButton>
|
||||||
|
{ badge }
|
||||||
|
{ addRoomButton }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} }
|
||||||
|
</RovingTabIndexWrapper>;
|
||||||
}
|
}
|
||||||
|
|
||||||
checkOverflow = () => {
|
checkOverflow = () => {
|
||||||
|
|
|
@ -133,9 +133,11 @@ export default createReactClass({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const clearButton = (!this.state.blurred || this.state.searchTerm) ?
|
const clearButton = (!this.state.blurred || this.state.searchTerm) ?
|
||||||
(<AccessibleButton key="button"
|
(<AccessibleButton
|
||||||
className="mx_SearchBox_closeButton"
|
key="button"
|
||||||
onClick={ () => {this._clearSearch("button"); } }>
|
tabIndex={-1}
|
||||||
|
className="mx_SearchBox_closeButton"
|
||||||
|
onClick={ () => {this._clearSearch("button"); } }>
|
||||||
</AccessibleButton>) : undefined;
|
</AccessibleButton>) : undefined;
|
||||||
|
|
||||||
// show a shorter placeholder when blurred, if requested
|
// show a shorter placeholder when blurred, if requested
|
||||||
|
|
|
@ -306,7 +306,7 @@ export default createReactClass({
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<MenuItem className="mx_RoomTileContextMenu_tag_field" onClick={this._onClickSettings}>
|
<MenuItem className="mx_RoomTileContextMenu_tag_field" onClick={this._onClickSettings}>
|
||||||
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icons-settings-room.svg")} width="15" height="15" alt="" />
|
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/feather-customised/settings.svg")} width="15" height="15" alt="" />
|
||||||
{ _t('Settings') }
|
{ _t('Settings') }
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -20,6 +20,8 @@ import { _t } from '../../../languageHandler';
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import {MatrixEvent} from "matrix-js-sdk";
|
import {MatrixEvent} from "matrix-js-sdk";
|
||||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||||
|
import SdkConfig from '../../../SdkConfig';
|
||||||
|
import Markdown from '../../../Markdown';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* A dialog for reporting an event.
|
* A dialog for reporting an event.
|
||||||
|
@ -95,6 +97,15 @@ export default class ReportEventDialog extends PureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminMessageMD =
|
||||||
|
SdkConfig.get().reportEvent &&
|
||||||
|
SdkConfig.get().reportEvent.adminMessageMD;
|
||||||
|
let adminMessage;
|
||||||
|
if (adminMessageMD) {
|
||||||
|
const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true });
|
||||||
|
adminMessage = <p dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
className="mx_BugReportDialog"
|
className="mx_BugReportDialog"
|
||||||
|
@ -110,7 +121,7 @@ export default class ReportEventDialog extends PureComponent {
|
||||||
"administrator will not be able to read the message text or view any files or images.")
|
"administrator will not be able to read the message text or view any files or images.")
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
{adminMessage}
|
||||||
<Field
|
<Field
|
||||||
id="mx_ReportEventDialog_reason"
|
id="mx_ReportEventDialog_reason"
|
||||||
className="mx_ReportEventDialog_reason"
|
className="mx_ReportEventDialog_reason"
|
||||||
|
|
|
@ -26,6 +26,7 @@ import classNames from 'classnames';
|
||||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||||
import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextMenu";
|
import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextMenu";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
|
import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
// XXX this class copies a lot from RoomTile.js
|
// XXX this class copies a lot from RoomTile.js
|
||||||
export default createReactClass({
|
export default createReactClass({
|
||||||
|
@ -127,7 +128,8 @@ export default createReactClass({
|
||||||
'mx_RoomTile_badgeShown': this.state.badgeHover || isMenuDisplayed,
|
'mx_RoomTile_badgeShown': this.state.badgeHover || isMenuDisplayed,
|
||||||
});
|
});
|
||||||
|
|
||||||
const label = <div title={this.props.group.groupId} className={nameClasses} dir="auto">
|
// XXX: this is a workaround for Firefox giving this div a tabstop :( [tabIndex]
|
||||||
|
const label = <div title={this.props.group.groupId} className={nameClasses} tabIndex={-1} dir="auto">
|
||||||
{ groupName }
|
{ groupName }
|
||||||
</div>;
|
</div>;
|
||||||
|
|
||||||
|
@ -137,16 +139,6 @@ export default createReactClass({
|
||||||
});
|
});
|
||||||
|
|
||||||
const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!';
|
const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!';
|
||||||
const badge = (
|
|
||||||
<ContextMenuButton
|
|
||||||
className={badgeClasses}
|
|
||||||
onClick={this.onContextMenuButtonClick}
|
|
||||||
label={_t("Options")}
|
|
||||||
isExpanded={isMenuDisplayed}
|
|
||||||
>
|
|
||||||
{ badgeContent }
|
|
||||||
</ContextMenuButton>
|
|
||||||
);
|
|
||||||
|
|
||||||
let tooltip;
|
let tooltip;
|
||||||
if (this.props.collapsed && this.state.hover) {
|
if (this.props.collapsed && this.state.hover) {
|
||||||
|
@ -171,22 +163,37 @@ export default createReactClass({
|
||||||
}
|
}
|
||||||
|
|
||||||
return <React.Fragment>
|
return <React.Fragment>
|
||||||
<AccessibleButton
|
<RovingTabIndexWrapper>
|
||||||
className={classes}
|
{({onFocus, isActive, ref}) =>
|
||||||
onClick={this.onClick}
|
<AccessibleButton
|
||||||
onMouseEnter={this.onMouseEnter}
|
onFocus={onFocus}
|
||||||
onMouseLeave={this.onMouseLeave}
|
tabIndex={isActive ? 0 : -1}
|
||||||
onContextMenu={this.onContextMenu}
|
inputRef={ref}
|
||||||
>
|
className={classes}
|
||||||
<div className="mx_RoomTile_avatar">
|
onClick={this.onClick}
|
||||||
{ av }
|
onMouseEnter={this.onMouseEnter}
|
||||||
</div>
|
onMouseLeave={this.onMouseLeave}
|
||||||
<div className="mx_RoomTile_nameContainer">
|
onContextMenu={this.onContextMenu}
|
||||||
{ label }
|
>
|
||||||
{ badge }
|
<div className="mx_RoomTile_avatar">
|
||||||
</div>
|
{ av }
|
||||||
{ tooltip }
|
</div>
|
||||||
</AccessibleButton>
|
<div className="mx_RoomTile_nameContainer">
|
||||||
|
{ label }
|
||||||
|
<ContextMenuButton
|
||||||
|
className={badgeClasses}
|
||||||
|
onClick={this.onContextMenuButtonClick}
|
||||||
|
label={_t("Options")}
|
||||||
|
isExpanded={isMenuDisplayed}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
>
|
||||||
|
{ badgeContent }
|
||||||
|
</ContextMenuButton>
|
||||||
|
</div>
|
||||||
|
{ tooltip }
|
||||||
|
</AccessibleButton>
|
||||||
|
}
|
||||||
|
</RovingTabIndexWrapper>
|
||||||
|
|
||||||
{ contextMenu }
|
{ contextMenu }
|
||||||
</React.Fragment>;
|
</React.Fragment>;
|
||||||
|
|
|
@ -64,10 +64,17 @@ const _getE2EStatus = (cli, userId, devices) => {
|
||||||
const hasUnverifiedDevice = devices.some((device) => device.isUnverified());
|
const hasUnverifiedDevice = devices.some((device) => device.isUnverified());
|
||||||
return hasUnverifiedDevice ? "warning" : "verified";
|
return hasUnverifiedDevice ? "warning" : "verified";
|
||||||
}
|
}
|
||||||
|
const isMe = userId === cli.getUserId();
|
||||||
const userVerified = cli.checkUserTrust(userId).isCrossSigningVerified();
|
const userVerified = cli.checkUserTrust(userId).isCrossSigningVerified();
|
||||||
const allDevicesVerified = devices.every(device => {
|
const allDevicesVerified = devices.every(device => {
|
||||||
const { deviceId } = device;
|
const { deviceId } = device;
|
||||||
return cli.checkDeviceTrust(userId, deviceId).isCrossSigningVerified();
|
// For your own devices, we use the stricter check of cross-signing
|
||||||
|
// verification to encourage everyone to trust their own devices via
|
||||||
|
// cross-signing so that other users can then safely trust you.
|
||||||
|
// For other people's devices, the more general verified check that
|
||||||
|
// includes locally verified devices can be used.
|
||||||
|
const deviceTrust = cli.checkDeviceTrust(userId, deviceId);
|
||||||
|
return isMe ? deviceTrust.isCrossSigningVerified() : deviceTrust.isVerified();
|
||||||
});
|
});
|
||||||
if (allDevicesVerified) {
|
if (allDevicesVerified) {
|
||||||
return userVerified ? "verified" : "normal";
|
return userVerified ? "verified" : "normal";
|
||||||
|
@ -128,19 +135,28 @@ function verifyUser(user) {
|
||||||
|
|
||||||
function DeviceItem({userId, device}) {
|
function DeviceItem({userId, device}) {
|
||||||
const cli = useContext(MatrixClientContext);
|
const cli = useContext(MatrixClientContext);
|
||||||
|
const isMe = userId === cli.getUserId();
|
||||||
const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId);
|
const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId);
|
||||||
|
// For your own devices, we use the stricter check of cross-signing
|
||||||
|
// verification to encourage everyone to trust their own devices via
|
||||||
|
// cross-signing so that other users can then safely trust you.
|
||||||
|
// For other people's devices, the more general verified check that
|
||||||
|
// includes locally verified devices can be used.
|
||||||
|
const isVerified = (isMe && SettingsStore.isFeatureEnabled("feature_cross_signing")) ?
|
||||||
|
deviceTrust.isCrossSigningVerified() :
|
||||||
|
deviceTrust.isVerified();
|
||||||
|
|
||||||
const classes = classNames("mx_UserInfo_device", {
|
const classes = classNames("mx_UserInfo_device", {
|
||||||
mx_UserInfo_device_verified: deviceTrust.isVerified(),
|
mx_UserInfo_device_verified: isVerified,
|
||||||
mx_UserInfo_device_unverified: !deviceTrust.isVerified(),
|
mx_UserInfo_device_unverified: !isVerified,
|
||||||
});
|
});
|
||||||
const iconClasses = classNames("mx_E2EIcon", {
|
const iconClasses = classNames("mx_E2EIcon", {
|
||||||
mx_E2EIcon_verified: deviceTrust.isVerified(),
|
mx_E2EIcon_verified: isVerified,
|
||||||
mx_E2EIcon_warning: !deviceTrust.isVerified(),
|
mx_E2EIcon_warning: !isVerified,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeviceClick = () => {
|
const onDeviceClick = () => {
|
||||||
if (!deviceTrust.isVerified()) {
|
if (!isVerified) {
|
||||||
verifyDevice(userId, device);
|
verifyDevice(userId, device);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -148,7 +164,7 @@ function DeviceItem({userId, device}) {
|
||||||
const deviceName = device.ambiguous ?
|
const deviceName = device.ambiguous ?
|
||||||
(device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" :
|
(device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" :
|
||||||
device.getDisplayName();
|
device.getDisplayName();
|
||||||
const trustedLabel = deviceTrust.isVerified() ? _t("Trusted") : _t("Not trusted");
|
const trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted");
|
||||||
return (<AccessibleButton className={classes} onClick={onDeviceClick}>
|
return (<AccessibleButton className={classes} onClick={onDeviceClick}>
|
||||||
<div className={iconClasses} />
|
<div className={iconClasses} />
|
||||||
<div className="mx_UserInfo_device_name">{deviceName}</div>
|
<div className="mx_UserInfo_device_name">{deviceName}</div>
|
||||||
|
@ -169,6 +185,7 @@ function DevicesSection({devices, userId, loading}) {
|
||||||
if (devices === null) {
|
if (devices === null) {
|
||||||
return _t("Unable to load device list");
|
return _t("Unable to load device list");
|
||||||
}
|
}
|
||||||
|
const isMe = userId === cli.getUserId();
|
||||||
const deviceTrusts = devices.map(d => cli.checkDeviceTrust(userId, d.deviceId));
|
const deviceTrusts = devices.map(d => cli.checkDeviceTrust(userId, d.deviceId));
|
||||||
|
|
||||||
const unverifiedDevices = [];
|
const unverifiedDevices = [];
|
||||||
|
@ -177,8 +194,16 @@ function DevicesSection({devices, userId, loading}) {
|
||||||
for (let i = 0; i < devices.length; ++i) {
|
for (let i = 0; i < devices.length; ++i) {
|
||||||
const device = devices[i];
|
const device = devices[i];
|
||||||
const deviceTrust = deviceTrusts[i];
|
const deviceTrust = deviceTrusts[i];
|
||||||
|
// For your own devices, we use the stricter check of cross-signing
|
||||||
|
// verification to encourage everyone to trust their own devices via
|
||||||
|
// cross-signing so that other users can then safely trust you.
|
||||||
|
// For other people's devices, the more general verified check that
|
||||||
|
// includes locally verified devices can be used.
|
||||||
|
const isVerified = (isMe && SettingsStore.isFeatureEnabled("feature_cross_signing")) ?
|
||||||
|
deviceTrust.isCrossSigningVerified() :
|
||||||
|
deviceTrust.isVerified();
|
||||||
|
|
||||||
if (deviceTrust.isVerified()) {
|
if (isVerified) {
|
||||||
verifiedDevices.push(device);
|
verifiedDevices.push(device);
|
||||||
} else {
|
} else {
|
||||||
unverifiedDevices.push(device);
|
unverifiedDevices.push(device);
|
||||||
|
@ -1277,18 +1302,24 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
|
||||||
text = _t("Messages in this room are end-to-end encrypted.");
|
text = _t("Messages in this room are end-to-end encrypted.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const userVerified = cli.checkUserTrust(user.userId).isVerified();
|
const userTrust = cli.checkUserTrust(user.userId);
|
||||||
|
const userVerified = SettingsStore.isFeatureEnabled("feature_cross_signing") ?
|
||||||
|
userTrust.isCrossSigningVerified() :
|
||||||
|
userTrust.isVerified();
|
||||||
const isMe = user.userId === cli.getUserId();
|
const isMe = user.userId === cli.getUserId();
|
||||||
let verifyButton;
|
let verifyButton;
|
||||||
if (!userVerified && !isMe) {
|
if (isRoomEncrypted && !userVerified && !isMe) {
|
||||||
verifyButton = <AccessibleButton className="mx_UserInfo_verify" onClick={() => verifyUser(user)}>
|
verifyButton = <AccessibleButton className="mx_UserInfo_verify" onClick={() => verifyUser(user)}>
|
||||||
{_t("Verify")}
|
{_t("Verify")}
|
||||||
</AccessibleButton>;
|
</AccessibleButton>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const devicesSection = <DevicesSection
|
let devicesSection;
|
||||||
loading={devices === undefined}
|
if (isRoomEncrypted) {
|
||||||
devices={devices} userId={user.userId} />;
|
devicesSection = <DevicesSection
|
||||||
|
loading={devices === undefined}
|
||||||
|
devices={devices} userId={user.userId} />;
|
||||||
|
}
|
||||||
|
|
||||||
const securitySection = (
|
const securitySection = (
|
||||||
<div className="mx_UserInfo_container">
|
<div className="mx_UserInfo_container">
|
||||||
|
|
|
@ -107,8 +107,9 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
});
|
});
|
||||||
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
|
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
|
||||||
if (emoticonMatch) {
|
if (emoticonMatch) {
|
||||||
const query = emoticonMatch[1].toLowerCase().replace("-", "");
|
const query = emoticonMatch[1].replace("-", "");
|
||||||
const data = EMOTICON_TO_EMOJI.get(query);
|
// try both exact match and lower-case, this means that xd won't match xD but :P will match :p
|
||||||
|
const data = EMOTICON_TO_EMOJI.get(query) || EMOTICON_TO_EMOJI.get(query.toLowerCase());
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const {partCreator} = model;
|
const {partCreator} = model;
|
||||||
|
|
|
@ -66,6 +66,13 @@ const stateEventTileTypes = {
|
||||||
'm.room.related_groups': 'messages.TextualEvent',
|
'm.room.related_groups': 'messages.TextualEvent',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const E2E_STATE = {
|
||||||
|
VERIFIED: "verified",
|
||||||
|
WARNING: "warning",
|
||||||
|
UNKNOWN: "unknown",
|
||||||
|
NORMAL: "normal",
|
||||||
|
};
|
||||||
|
|
||||||
// Add all the Mjolnir stuff to the renderer
|
// Add all the Mjolnir stuff to the renderer
|
||||||
for (const evType of ALL_RULE_TYPES) {
|
for (const evType of ALL_RULE_TYPES) {
|
||||||
stateEventTileTypes[evType] = 'messages.TextualEvent';
|
stateEventTileTypes[evType] = 'messages.TextualEvent';
|
||||||
|
@ -235,6 +242,7 @@ export default createReactClass({
|
||||||
this._suppressReadReceiptAnimation = false;
|
this._suppressReadReceiptAnimation = false;
|
||||||
const client = this.context;
|
const client = this.context;
|
||||||
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
|
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
|
||||||
|
client.on("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||||
this.props.mxEvent.on("Event.decrypted", this._onDecrypted);
|
this.props.mxEvent.on("Event.decrypted", this._onDecrypted);
|
||||||
if (this.props.showReactions) {
|
if (this.props.showReactions) {
|
||||||
this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
|
this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
|
||||||
|
@ -260,6 +268,7 @@ export default createReactClass({
|
||||||
componentWillUnmount: function() {
|
componentWillUnmount: function() {
|
||||||
const client = this.context;
|
const client = this.context;
|
||||||
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
|
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
|
||||||
|
client.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||||
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
|
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
|
||||||
if (this.props.showReactions) {
|
if (this.props.showReactions) {
|
||||||
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
|
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
|
||||||
|
@ -282,18 +291,56 @@ export default createReactClass({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onUserVerificationChanged: function(userId, _trustStatus) {
|
||||||
|
if (userId === this.props.mxEvent.getSender()) {
|
||||||
|
this._verifyEvent(this.props.mxEvent);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_verifyEvent: async function(mxEvent) {
|
_verifyEvent: async function(mxEvent) {
|
||||||
if (!mxEvent.isEncrypted()) {
|
if (!mxEvent.isEncrypted()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we directly trust the device, short-circuit here
|
||||||
const verified = await this.context.isEventSenderVerified(mxEvent);
|
const verified = await this.context.isEventSenderVerified(mxEvent);
|
||||||
|
if (verified) {
|
||||||
|
this.setState({
|
||||||
|
verified: E2E_STATE.VERIFIED,
|
||||||
|
}, () => {
|
||||||
|
// Decryption may have caused a change in size
|
||||||
|
this.props.onHeightChanged();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If cross-signing is off, the old behaviour is to scream at the user
|
||||||
|
// as if they've done something wrong, which they haven't
|
||||||
|
if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) {
|
||||||
|
this.setState({
|
||||||
|
verified: E2E_STATE.WARNING,
|
||||||
|
}, this.props.onHeightChanged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.context.checkUserTrust(mxEvent.getSender()).isCrossSigningVerified()) {
|
||||||
|
this.setState({
|
||||||
|
verified: E2E_STATE.NORMAL,
|
||||||
|
}, this.props.onHeightChanged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventSenderTrust = await this.context.checkEventSenderTrust(mxEvent);
|
||||||
|
if (!eventSenderTrust) {
|
||||||
|
this.setState({
|
||||||
|
verified: E2E_STATE.UNKNOWN,
|
||||||
|
}, this.props.onHeightChanged); // Decryption may have cause a change in size
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
verified: verified,
|
verified: eventSenderTrust.isVerified() ? E2E_STATE.VERIFIED : E2E_STATE.WARNING,
|
||||||
}, () => {
|
}, this.props.onHeightChanged); // Decryption may have caused a change in size
|
||||||
// Decryption may have caused a change in size
|
|
||||||
this.props.onHeightChanged();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_propsEqual: function(objA, objB) {
|
_propsEqual: function(objA, objB) {
|
||||||
|
@ -473,8 +520,12 @@ export default createReactClass({
|
||||||
|
|
||||||
// event is encrypted, display padlock corresponding to whether or not it is verified
|
// event is encrypted, display padlock corresponding to whether or not it is verified
|
||||||
if (ev.isEncrypted()) {
|
if (ev.isEncrypted()) {
|
||||||
if (this.state.verified) {
|
if (this.state.verified === E2E_STATE.NORMAL) {
|
||||||
|
return; // no icon if we've not even cross-signed the user
|
||||||
|
} else if (this.state.verified === E2E_STATE.VERIFIED) {
|
||||||
return; // no icon for verified
|
return; // no icon for verified
|
||||||
|
} else if (this.state.verified === E2E_STATE.UNKNOWN) {
|
||||||
|
return (<E2ePadlockUnknown />);
|
||||||
} else {
|
} else {
|
||||||
return (<E2ePadlockUnverified />);
|
return (<E2ePadlockUnverified />);
|
||||||
}
|
}
|
||||||
|
@ -604,8 +655,9 @@ export default createReactClass({
|
||||||
mx_EventTile_last: this.props.last,
|
mx_EventTile_last: this.props.last,
|
||||||
mx_EventTile_contextual: this.props.contextual,
|
mx_EventTile_contextual: this.props.contextual,
|
||||||
mx_EventTile_actionBarFocused: this.state.actionBarFocused,
|
mx_EventTile_actionBarFocused: this.state.actionBarFocused,
|
||||||
mx_EventTile_verified: !isBubbleMessage && this.state.verified === true,
|
mx_EventTile_verified: !isBubbleMessage && this.state.verified === E2E_STATE.VERIFIED,
|
||||||
mx_EventTile_unverified: !isBubbleMessage && this.state.verified === false,
|
mx_EventTile_unverified: !isBubbleMessage && this.state.verified === E2E_STATE.WARNING,
|
||||||
|
mx_EventTile_unknown: !isBubbleMessage && this.state.verified === E2E_STATE.UNKNOWN,
|
||||||
mx_EventTile_bad: isEncryptionFailure,
|
mx_EventTile_bad: isEncryptionFailure,
|
||||||
mx_EventTile_emote: msgtype === 'm.emote',
|
mx_EventTile_emote: msgtype === 'm.emote',
|
||||||
mx_EventTile_redacted: isRedacted,
|
mx_EventTile_redacted: isRedacted,
|
||||||
|
@ -901,6 +953,12 @@ function E2ePadlockUnencrypted(props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function E2ePadlockUnknown(props) {
|
||||||
|
return (
|
||||||
|
<E2ePadlock title={_t("Encrypted by a deleted device")} icon="unknown" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class E2ePadlock extends React.Component {
|
class E2ePadlock extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
icon: PropTypes.string.isRequired,
|
icon: PropTypes.string.isRequired,
|
||||||
|
|
|
@ -310,8 +310,7 @@ export default createReactClass({
|
||||||
return (
|
return (
|
||||||
<div className="mx_RoomHeader light-panel">
|
<div className="mx_RoomHeader light-panel">
|
||||||
<div className="mx_RoomHeader_wrapper">
|
<div className="mx_RoomHeader_wrapper">
|
||||||
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
|
<div className="mx_RoomHeader_avatar">{ roomAvatar }{ e2eIcon }</div>
|
||||||
{ e2eIcon }
|
|
||||||
{ privateIcon }
|
{ privateIcon }
|
||||||
{ name }
|
{ name }
|
||||||
{ topicElement }
|
{ topicElement }
|
||||||
|
|
|
@ -39,6 +39,7 @@ import * as sdk from "../../../index";
|
||||||
import * as Receipt from "../../../utils/Receipt";
|
import * as Receipt from "../../../utils/Receipt";
|
||||||
import {Resizer} from '../../../resizer';
|
import {Resizer} from '../../../resizer';
|
||||||
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
|
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
|
||||||
|
import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
const HIDE_CONFERENCE_CHANS = true;
|
const HIDE_CONFERENCE_CHANS = true;
|
||||||
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
|
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
|
||||||
|
@ -776,19 +777,22 @@ export default createReactClass({
|
||||||
|
|
||||||
const subListComponents = this._mapSubListProps(subLists);
|
const subListComponents = this._mapSubListProps(subLists);
|
||||||
|
|
||||||
const {resizeNotifier, collapsed, searchFilter, ConferenceHandler, ...props} = this.props; // eslint-disable-line
|
const {resizeNotifier, collapsed, searchFilter, ConferenceHandler, onKeyDown, ...props} = this.props; // eslint-disable-line
|
||||||
return (
|
return (
|
||||||
<div
|
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
|
||||||
{...props}
|
{({onKeyDownHandler}) => <div
|
||||||
ref={this._collectResizeContainer}
|
{...props}
|
||||||
className="mx_RoomList"
|
onKeyDown={onKeyDownHandler}
|
||||||
role="tree"
|
ref={this._collectResizeContainer}
|
||||||
aria-label={_t("Rooms")}
|
className="mx_RoomList"
|
||||||
onMouseMove={this.onMouseMove}
|
role="tree"
|
||||||
onMouseLeave={this.onMouseLeave}
|
aria-label={_t("Rooms")}
|
||||||
>
|
onMouseMove={this.onMouseMove}
|
||||||
{ subListComponents }
|
onMouseLeave={this.onMouseLeave}
|
||||||
</div>
|
>
|
||||||
|
{ subListComponents }
|
||||||
|
</div> }
|
||||||
|
</RovingTabIndexProvider>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -32,6 +32,7 @@ import ActiveRoomObserver from '../../../ActiveRoomObserver';
|
||||||
import RoomViewStore from '../../../stores/RoomViewStore';
|
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import {_t} from "../../../languageHandler";
|
import {_t} from "../../../languageHandler";
|
||||||
|
import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
export default createReactClass({
|
export default createReactClass({
|
||||||
displayName: 'RoomTile',
|
displayName: 'RoomTile',
|
||||||
|
@ -352,7 +353,8 @@ export default createReactClass({
|
||||||
});
|
});
|
||||||
|
|
||||||
subtextLabel = subtext ? <span className="mx_RoomTile_subtext">{ subtext }</span> : null;
|
subtextLabel = subtext ? <span className="mx_RoomTile_subtext">{ subtext }</span> : null;
|
||||||
label = <div title={name} className={nameClasses} dir="auto">{ name }</div>;
|
// XXX: this is a workaround for Firefox giving this div a tabstop :( [tabIndex]
|
||||||
|
label = <div title={name} className={nameClasses} tabIndex={-1} dir="auto">{ name }</div>;
|
||||||
} else if (this.state.hover) {
|
} else if (this.state.hover) {
|
||||||
const Tooltip = sdk.getComponent("elements.Tooltip");
|
const Tooltip = sdk.getComponent("elements.Tooltip");
|
||||||
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto" />;
|
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto" />;
|
||||||
|
@ -432,36 +434,42 @@ export default createReactClass({
|
||||||
}
|
}
|
||||||
|
|
||||||
return <React.Fragment>
|
return <React.Fragment>
|
||||||
<AccessibleButton
|
<RovingTabIndexWrapper>
|
||||||
tabIndex="0"
|
{({onFocus, isActive, ref}) =>
|
||||||
className={classes}
|
<AccessibleButton
|
||||||
onClick={this.onClick}
|
onFocus={onFocus}
|
||||||
onMouseEnter={this.onMouseEnter}
|
tabIndex={isActive ? 0 : -1}
|
||||||
onMouseLeave={this.onMouseLeave}
|
inputRef={ref}
|
||||||
onContextMenu={this.onContextMenu}
|
className={classes}
|
||||||
aria-label={ariaLabel}
|
onClick={this.onClick}
|
||||||
aria-selected={this.state.selected}
|
onMouseEnter={this.onMouseEnter}
|
||||||
role="treeitem"
|
onMouseLeave={this.onMouseLeave}
|
||||||
>
|
onContextMenu={this.onContextMenu}
|
||||||
<div className={avatarClasses}>
|
aria-label={ariaLabel}
|
||||||
<div className="mx_RoomTile_avatar_container">
|
aria-selected={this.state.selected}
|
||||||
<RoomAvatar room={this.props.room} width={24} height={24} />
|
role="treeitem"
|
||||||
{ dmIndicator }
|
>
|
||||||
</div>
|
<div className={avatarClasses}>
|
||||||
</div>
|
<div className="mx_RoomTile_avatar_container">
|
||||||
{ privateIcon }
|
<RoomAvatar room={this.props.room} width={24} height={24} />
|
||||||
<div className="mx_RoomTile_nameContainer">
|
{ dmIndicator }
|
||||||
<div className="mx_RoomTile_labelContainer">
|
</div>
|
||||||
{ label }
|
</div>
|
||||||
{ subtextLabel }
|
{ privateIcon }
|
||||||
</div>
|
<div className="mx_RoomTile_nameContainer">
|
||||||
{ dmOnline }
|
<div className="mx_RoomTile_labelContainer">
|
||||||
{ contextMenuButton }
|
{ label }
|
||||||
{ badge }
|
{ subtextLabel }
|
||||||
</div>
|
</div>
|
||||||
{ /* { incomingCallBox } */ }
|
{ dmOnline }
|
||||||
{ tooltip }
|
{ contextMenuButton }
|
||||||
</AccessibleButton>
|
{ badge }
|
||||||
|
</div>
|
||||||
|
{ /* { incomingCallBox } */ }
|
||||||
|
{ tooltip }
|
||||||
|
</AccessibleButton>
|
||||||
|
}
|
||||||
|
</RovingTabIndexWrapper>
|
||||||
|
|
||||||
{ contextMenu }
|
{ contextMenu }
|
||||||
</React.Fragment>;
|
</React.Fragment>;
|
||||||
|
|
|
@ -24,6 +24,8 @@ import {
|
||||||
containsEmote,
|
containsEmote,
|
||||||
stripEmoteCommand,
|
stripEmoteCommand,
|
||||||
unescapeMessage,
|
unescapeMessage,
|
||||||
|
startsWith,
|
||||||
|
stripPrefix,
|
||||||
} from '../../../editor/serialize';
|
} from '../../../editor/serialize';
|
||||||
import {CommandPartCreator} from '../../../editor/parts';
|
import {CommandPartCreator} from '../../../editor/parts';
|
||||||
import BasicMessageComposer from "./BasicMessageComposer";
|
import BasicMessageComposer from "./BasicMessageComposer";
|
||||||
|
@ -33,7 +35,7 @@ import ReplyThread from "../elements/ReplyThread";
|
||||||
import {parseEvent} from '../../../editor/deserialize';
|
import {parseEvent} from '../../../editor/deserialize';
|
||||||
import {findEditableEvent} from '../../../utils/EventUtils';
|
import {findEditableEvent} from '../../../utils/EventUtils';
|
||||||
import SendHistoryManager from "../../../SendHistoryManager";
|
import SendHistoryManager from "../../../SendHistoryManager";
|
||||||
import {processCommandInput} from '../../../SlashCommands';
|
import {getCommand} from '../../../SlashCommands';
|
||||||
import * as sdk from '../../../index';
|
import * as sdk from '../../../index';
|
||||||
import Modal from '../../../Modal';
|
import Modal from '../../../Modal';
|
||||||
import {_t, _td} from '../../../languageHandler';
|
import {_t, _td} from '../../../languageHandler';
|
||||||
|
@ -56,11 +58,15 @@ function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMessageContent(model, permalinkCreator) {
|
// exported for tests
|
||||||
|
export function createMessageContent(model, permalinkCreator) {
|
||||||
const isEmote = containsEmote(model);
|
const isEmote = containsEmote(model);
|
||||||
if (isEmote) {
|
if (isEmote) {
|
||||||
model = stripEmoteCommand(model);
|
model = stripEmoteCommand(model);
|
||||||
}
|
}
|
||||||
|
if (startsWith(model, "//")) {
|
||||||
|
model = stripPrefix(model, "/");
|
||||||
|
}
|
||||||
model = unescapeMessage(model);
|
model = unescapeMessage(model);
|
||||||
const repliedToEvent = RoomViewStore.getQuotingEvent();
|
const repliedToEvent = RoomViewStore.getQuotingEvent();
|
||||||
|
|
||||||
|
@ -175,20 +181,21 @@ export default class SendMessageComposer extends React.Component {
|
||||||
const parts = this.model.parts;
|
const parts = this.model.parts;
|
||||||
const firstPart = parts[0];
|
const firstPart = parts[0];
|
||||||
if (firstPart) {
|
if (firstPart) {
|
||||||
if (firstPart.type === "command") {
|
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// be extra resilient when somehow the AutocompleteWrapperModel or
|
// be extra resilient when somehow the AutocompleteWrapperModel or
|
||||||
// CommandPartCreator fails to insert a command part, so we don't send
|
// CommandPartCreator fails to insert a command part, so we don't send
|
||||||
// a command as a message
|
// a command as a message
|
||||||
if (firstPart.text.startsWith("/") && (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
|
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
|
||||||
|
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _runSlashCommand() {
|
_getSlashCommand() {
|
||||||
const commandText = this.model.parts.reduce((text, part) => {
|
const commandText = this.model.parts.reduce((text, part) => {
|
||||||
// use mxid to textify user pills in a command
|
// use mxid to textify user pills in a command
|
||||||
if (part.type === "user-pill") {
|
if (part.type === "user-pill") {
|
||||||
|
@ -196,50 +203,86 @@ export default class SendMessageComposer extends React.Component {
|
||||||
}
|
}
|
||||||
return text + part.text;
|
return text + part.text;
|
||||||
}, "");
|
}, "");
|
||||||
const cmd = processCommandInput(this.props.room.roomId, commandText);
|
return [getCommand(this.props.room.roomId, commandText), commandText];
|
||||||
|
}
|
||||||
|
|
||||||
if (cmd) {
|
async _runSlashCommand(fn) {
|
||||||
let error = cmd.error;
|
const cmd = fn();
|
||||||
if (cmd.promise) {
|
let error = cmd.error;
|
||||||
try {
|
if (cmd.promise) {
|
||||||
await cmd.promise;
|
try {
|
||||||
} catch (err) {
|
await cmd.promise;
|
||||||
error = err;
|
} catch (err) {
|
||||||
}
|
error = err;
|
||||||
}
|
}
|
||||||
if (error) {
|
}
|
||||||
console.error("Command failure: %s", error);
|
if (error) {
|
||||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
console.error("Command failure: %s", error);
|
||||||
// assume the error is a server error when the command is async
|
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||||
const isServerError = !!cmd.promise;
|
// assume the error is a server error when the command is async
|
||||||
const title = isServerError ? _td("Server error") : _td("Command error");
|
const isServerError = !!cmd.promise;
|
||||||
|
const title = isServerError ? _td("Server error") : _td("Command error");
|
||||||
|
|
||||||
let errText;
|
let errText;
|
||||||
if (typeof error === 'string') {
|
if (typeof error === 'string') {
|
||||||
errText = error;
|
errText = error;
|
||||||
} else if (error.message) {
|
} else if (error.message) {
|
||||||
errText = error.message;
|
errText = error.message;
|
||||||
} else {
|
|
||||||
errText = _t("Server unavailable, overloaded, or something else went wrong.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Modal.createTrackedDialog(title, '', ErrorDialog, {
|
|
||||||
title: _t(title),
|
|
||||||
description: errText,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
console.log("Command success.");
|
errText = _t("Server unavailable, overloaded, or something else went wrong.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Modal.createTrackedDialog(title, '', ErrorDialog, {
|
||||||
|
title: _t(title),
|
||||||
|
description: errText,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("Command success.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_sendMessage() {
|
async _sendMessage() {
|
||||||
if (this.model.isEmpty) {
|
if (this.model.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let shouldSend = true;
|
||||||
|
|
||||||
if (!containsEmote(this.model) && this._isSlashCommand()) {
|
if (!containsEmote(this.model) && this._isSlashCommand()) {
|
||||||
this._runSlashCommand();
|
const [cmd, commandText] = this._getSlashCommand();
|
||||||
} else {
|
if (cmd) {
|
||||||
|
shouldSend = false;
|
||||||
|
this._runSlashCommand(cmd);
|
||||||
|
} else {
|
||||||
|
// ask the user if their unknown command should be sent as a message
|
||||||
|
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||||
|
const {finished} = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
|
||||||
|
title: _t("Unknown Command"),
|
||||||
|
description: <div>
|
||||||
|
<p>
|
||||||
|
{ _t("Unrecognised command: %(commandText)s", {commandText}) }
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{ _t("You can use <code>/help</code> to list available commands. " +
|
||||||
|
"Did you mean to send this as a message?", {}, {
|
||||||
|
code: t => <code>{ t }</code>,
|
||||||
|
}) }
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{ _t("Hint: Begin your message with <code>//</code> to start it with a slash.", {}, {
|
||||||
|
code: t => <code>{ t }</code>,
|
||||||
|
}) }
|
||||||
|
</p>
|
||||||
|
</div>,
|
||||||
|
button: _t('Send as message'),
|
||||||
|
});
|
||||||
|
const [sendAnyway] = await finished;
|
||||||
|
// if !sendAnyway bail to let the user edit the composer and try again
|
||||||
|
if (!sendAnyway) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldSend) {
|
||||||
const isReply = !!RoomViewStore.getQuotingEvent();
|
const isReply = !!RoomViewStore.getQuotingEvent();
|
||||||
const {roomId} = this.props.room;
|
const {roomId} = this.props.room;
|
||||||
const content = createMessageContent(this.model, this.props.permalinkCreator);
|
const content = createMessageContent(this.model, this.props.permalinkCreator);
|
||||||
|
@ -253,6 +296,7 @@ export default class SendMessageComposer extends React.Component {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendHistoryManager.save(this.model);
|
this.sendHistoryManager.save(this.model);
|
||||||
// clear composer
|
// clear composer
|
||||||
this.model.reset([]);
|
this.model.reset([]);
|
||||||
|
@ -326,7 +370,8 @@ export default class SendMessageComposer extends React.Component {
|
||||||
member.rawDisplayName : userId;
|
member.rawDisplayName : userId;
|
||||||
const caret = this._editorRef.getCaret();
|
const caret = this._editorRef.getCaret();
|
||||||
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
|
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
|
||||||
const insertIndex = position.index + 1;
|
// index is -1 if there are no parts but we only care for if this would be the part in position 0
|
||||||
|
const insertIndex = position.index > 0 ? position.index : 0;
|
||||||
const parts = partCreator.createMentionParts(insertIndex, displayName, userId);
|
const parts = partCreator.createMentionParts(insertIndex, displayName, userId);
|
||||||
model.transform(() => {
|
model.transform(() => {
|
||||||
const addedLen = model.insert(parts, position);
|
const addedLen = model.insert(parts, position);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
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.
|
||||||
|
@ -29,7 +29,9 @@ export default class CrossSigningPanel extends React.PureComponent {
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
error: null,
|
error: null,
|
||||||
...this._getUpdatedStatus(),
|
crossSigningPublicKeysOnDevice: false,
|
||||||
|
crossSigningPrivateKeysInStorage: false,
|
||||||
|
secretStorageKeyInAccount: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,6 +40,7 @@ export default class CrossSigningPanel extends React.PureComponent {
|
||||||
cli.on("accountData", this.onAccountData);
|
cli.on("accountData", this.onAccountData);
|
||||||
cli.on("userTrustStatusChanged", this.onStatusChanged);
|
cli.on("userTrustStatusChanged", this.onStatusChanged);
|
||||||
cli.on("crossSigning.keysChanged", this.onStatusChanged);
|
cli.on("crossSigning.keysChanged", this.onStatusChanged);
|
||||||
|
this._getUpdatedStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
@ -52,12 +55,12 @@ export default class CrossSigningPanel extends React.PureComponent {
|
||||||
onAccountData = (event) => {
|
onAccountData = (event) => {
|
||||||
const type = event.getType();
|
const type = event.getType();
|
||||||
if (type.startsWith("m.cross_signing") || type.startsWith("m.secret_storage")) {
|
if (type.startsWith("m.cross_signing") || type.startsWith("m.secret_storage")) {
|
||||||
this.setState(this._getUpdatedStatus());
|
this._getUpdatedStatus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onStatusChanged = () => {
|
onStatusChanged = () => {
|
||||||
this.setState(this._getUpdatedStatus());
|
this._getUpdatedStatus();
|
||||||
};
|
};
|
||||||
|
|
||||||
async _getUpdatedStatus() {
|
async _getUpdatedStatus() {
|
||||||
|
@ -69,11 +72,11 @@ export default class CrossSigningPanel extends React.PureComponent {
|
||||||
const crossSigningPrivateKeysInStorage = await crossSigning.isStoredInSecretStorage(secretStorage);
|
const crossSigningPrivateKeysInStorage = await crossSigning.isStoredInSecretStorage(secretStorage);
|
||||||
const secretStorageKeyInAccount = await secretStorage.hasKey();
|
const secretStorageKeyInAccount = await secretStorage.hasKey();
|
||||||
|
|
||||||
return {
|
this.setState({
|
||||||
crossSigningPublicKeysOnDevice,
|
crossSigningPublicKeysOnDevice,
|
||||||
crossSigningPrivateKeysInStorage,
|
crossSigningPrivateKeysInStorage,
|
||||||
secretStorageKeyInAccount,
|
secretStorageKeyInAccount,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -93,7 +96,7 @@ export default class CrossSigningPanel extends React.PureComponent {
|
||||||
console.error("Error bootstrapping secret storage", e);
|
console.error("Error bootstrapping secret storage", e);
|
||||||
}
|
}
|
||||||
if (this._unmounted) return;
|
if (this._unmounted) return;
|
||||||
this.setState(this._getUpdatedStatus());
|
this._getUpdatedStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
|
@ -21,15 +21,10 @@ import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
|
||||||
import AccessibleButton from "../../../elements/AccessibleButton";
|
import AccessibleButton from "../../../elements/AccessibleButton";
|
||||||
import SdkConfig from "../../../../../SdkConfig";
|
import SdkConfig from "../../../../../SdkConfig";
|
||||||
import createRoom from "../../../../../createRoom";
|
import createRoom from "../../../../../createRoom";
|
||||||
import packageJson from "../../../../../../package.json";
|
|
||||||
import Modal from "../../../../../Modal";
|
import Modal from "../../../../../Modal";
|
||||||
import * as sdk from "../../../../../";
|
import * as sdk from "../../../../../";
|
||||||
import PlatformPeg from "../../../../../PlatformPeg";
|
import PlatformPeg from "../../../../../PlatformPeg";
|
||||||
|
|
||||||
// if this looks like a release, use the 'version' from package.json; else use
|
|
||||||
// the git sha. Prepend version with v, to look like riot-web version
|
|
||||||
const REACT_SDK_VERSION = 'dist' in packageJson ? packageJson.version : packageJson.gitHead || '<local>';
|
|
||||||
|
|
||||||
// Simple method to help prettify GH Release Tags and Commit Hashes.
|
// Simple method to help prettify GH Release Tags and Commit Hashes.
|
||||||
const semVerRegex = /^v?(\d+\.\d+\.\d+(?:-rc.+)?)(?:-(?:\d+-g)?([0-9a-fA-F]+))?(?:-dirty)?$/i;
|
const semVerRegex = /^v?(\d+\.\d+\.\d+(?:-rc.+)?)(?:-(?:\d+-g)?([0-9a-fA-F]+))?(?:-dirty)?$/i;
|
||||||
const ghVersionLabel = function(repo, token='') {
|
const ghVersionLabel = function(repo, token='') {
|
||||||
|
@ -188,9 +183,6 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reactSdkVersion = REACT_SDK_VERSION !== '<local>'
|
|
||||||
? ghVersionLabel('matrix-org/matrix-react-sdk', REACT_SDK_VERSION)
|
|
||||||
: REACT_SDK_VERSION;
|
|
||||||
const vectorVersion = this.state.vectorVersion
|
const vectorVersion = this.state.vectorVersion
|
||||||
? ghVersionLabel('vector-im/riot-web', this.state.vectorVersion)
|
? ghVersionLabel('vector-im/riot-web', this.state.vectorVersion)
|
||||||
: 'unknown';
|
: 'unknown';
|
||||||
|
@ -243,7 +235,6 @@ export default class HelpUserSettingsTab extends React.Component {
|
||||||
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
|
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
|
||||||
<span className='mx_SettingsTab_subheading'>{_t("Versions")}</span>
|
<span className='mx_SettingsTab_subheading'>{_t("Versions")}</span>
|
||||||
<div className='mx_SettingsTab_subsectionText'>
|
<div className='mx_SettingsTab_subsectionText'>
|
||||||
{_t("matrix-react-sdk version:")} {reactSdkVersion}<br />
|
|
||||||
{_t("riot-web version:")} {vectorVersion}<br />
|
{_t("riot-web version:")} {vectorVersion}<br />
|
||||||
{_t("olm version:")} {olmVersion}<br />
|
{_t("olm version:")} {olmVersion}<br />
|
||||||
{updateButton}
|
{updateButton}
|
||||||
|
|
|
@ -61,18 +61,26 @@ export function textSerialize(model) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function containsEmote(model) {
|
export function containsEmote(model) {
|
||||||
|
return startsWith(model, "/me ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startsWith(model, prefix) {
|
||||||
const firstPart = model.parts[0];
|
const firstPart = model.parts[0];
|
||||||
// part type will be "plain" while editing,
|
// part type will be "plain" while editing,
|
||||||
// and "command" while composing a message.
|
// and "command" while composing a message.
|
||||||
return firstPart &&
|
return firstPart &&
|
||||||
(firstPart.type === "plain" || firstPart.type === "command") &&
|
(firstPart.type === "plain" || firstPart.type === "command") &&
|
||||||
firstPart.text.startsWith("/me ");
|
firstPart.text.startsWith(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripEmoteCommand(model) {
|
export function stripEmoteCommand(model) {
|
||||||
// trim "/me "
|
// trim "/me "
|
||||||
|
return stripPrefix(model, "/me ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripPrefix(model, prefix) {
|
||||||
model = model.clone();
|
model = model.clone();
|
||||||
model.removeText({index: 0, offset: 0}, 4);
|
model.removeText({index: 0, offset: 0}, prefix.length);
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -200,7 +200,6 @@
|
||||||
"Sends the given message coloured as a rainbow": "Sends the given message coloured as a rainbow",
|
"Sends the given message coloured as a rainbow": "Sends the given message coloured as a rainbow",
|
||||||
"Sends the given emote coloured as a rainbow": "Sends the given emote coloured as a rainbow",
|
"Sends the given emote coloured as a rainbow": "Sends the given emote coloured as a rainbow",
|
||||||
"Displays list of commands with usages and descriptions": "Displays list of commands with usages and descriptions",
|
"Displays list of commands with usages and descriptions": "Displays list of commands with usages and descriptions",
|
||||||
"Unrecognised command:": "Unrecognised command:",
|
|
||||||
"Reason": "Reason",
|
"Reason": "Reason",
|
||||||
"%(targetName)s accepted the invitation for %(displayName)s.": "%(targetName)s accepted the invitation for %(displayName)s.",
|
"%(targetName)s accepted the invitation for %(displayName)s.": "%(targetName)s accepted the invitation for %(displayName)s.",
|
||||||
"%(targetName)s accepted an invitation.": "%(targetName)s accepted an invitation.",
|
"%(targetName)s accepted an invitation.": "%(targetName)s accepted an invitation.",
|
||||||
|
@ -688,7 +687,6 @@
|
||||||
"Clear cache and reload": "Clear cache and reload",
|
"Clear cache and reload": "Clear cache and reload",
|
||||||
"FAQ": "FAQ",
|
"FAQ": "FAQ",
|
||||||
"Versions": "Versions",
|
"Versions": "Versions",
|
||||||
"matrix-react-sdk version:": "matrix-react-sdk version:",
|
|
||||||
"riot-web version:": "riot-web version:",
|
"riot-web version:": "riot-web version:",
|
||||||
"olm version:": "olm version:",
|
"olm version:": "olm version:",
|
||||||
"Homeserver is": "Homeserver is",
|
"Homeserver is": "Homeserver is",
|
||||||
|
@ -905,6 +903,7 @@
|
||||||
"This message cannot be decrypted": "This message cannot be decrypted",
|
"This message cannot be decrypted": "This message cannot be decrypted",
|
||||||
"Encrypted by an unverified device": "Encrypted by an unverified device",
|
"Encrypted by an unverified device": "Encrypted by an unverified device",
|
||||||
"Unencrypted": "Unencrypted",
|
"Unencrypted": "Unencrypted",
|
||||||
|
"Encrypted by a deleted device": "Encrypted by a deleted device",
|
||||||
"Please select the destination room for this message": "Please select the destination room for this message",
|
"Please select the destination room for this message": "Please select the destination room for this message",
|
||||||
"Scroll to bottom of page": "Scroll to bottom of page",
|
"Scroll to bottom of page": "Scroll to bottom of page",
|
||||||
"Close preview": "Close preview",
|
"Close preview": "Close preview",
|
||||||
|
@ -1077,6 +1076,11 @@
|
||||||
"Server error": "Server error",
|
"Server error": "Server error",
|
||||||
"Command error": "Command error",
|
"Command error": "Command error",
|
||||||
"Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.",
|
"Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.",
|
||||||
|
"Unknown Command": "Unknown Command",
|
||||||
|
"Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s",
|
||||||
|
"You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?",
|
||||||
|
"Hint: Begin your message with <code>//</code> to start it with a slash.": "Hint: Begin your message with <code>//</code> to start it with a slash.",
|
||||||
|
"Send as message": "Send as message",
|
||||||
"Failed to connect to integration manager": "Failed to connect to integration manager",
|
"Failed to connect to integration manager": "Failed to connect to integration manager",
|
||||||
"You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled",
|
"You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled",
|
||||||
"Add some now": "Add some now",
|
"Add some now": "Add some now",
|
||||||
|
|
121
test/accessibility/RovingTabIndex-test.js
Normal file
121
test/accessibility/RovingTabIndex-test.js
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Adapter from "enzyme-adapter-react-16";
|
||||||
|
import { configure, mount } from "enzyme";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RovingTabIndexProvider,
|
||||||
|
RovingTabIndexWrapper,
|
||||||
|
useRovingTabIndex,
|
||||||
|
} from "../../src/accessibility/RovingTabIndex";
|
||||||
|
|
||||||
|
configure({ adapter: new Adapter() });
|
||||||
|
|
||||||
|
const Button = (props) => {
|
||||||
|
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||||
|
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkTabIndexes = (buttons, expectations) => {
|
||||||
|
expect(buttons.length).toBe(expectations.length);
|
||||||
|
for (let i = 0; i < buttons.length; i++) {
|
||||||
|
expect(buttons.at(i).prop("tabIndex")).toBe(expectations[i]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// give the buttons keys for the fibre reconciler to not treat them all as the same
|
||||||
|
const button1 = <Button key={1}>a</Button>;
|
||||||
|
const button2 = <Button key={2}>b</Button>;
|
||||||
|
const button3 = <Button key={3}>c</Button>;
|
||||||
|
const button4 = <Button key={4}>d</Button>;
|
||||||
|
|
||||||
|
describe("RovingTabIndex", () => {
|
||||||
|
it("RovingTabIndexProvider renders children as expected", () => {
|
||||||
|
const wrapper = mount(<RovingTabIndexProvider>
|
||||||
|
{() => <div><span>Test</span></div>}
|
||||||
|
</RovingTabIndexProvider>);
|
||||||
|
expect(wrapper.text()).toBe("Test");
|
||||||
|
expect(wrapper.html()).toBe('<div><span>Test</span></div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => {
|
||||||
|
const wrapper = mount(<RovingTabIndexProvider>
|
||||||
|
{() => <React.Fragment>
|
||||||
|
{ button1 }
|
||||||
|
{ button2 }
|
||||||
|
{ button3 }
|
||||||
|
</React.Fragment>}
|
||||||
|
</RovingTabIndexProvider>);
|
||||||
|
|
||||||
|
// should begin with 0th being active
|
||||||
|
checkTabIndexes(wrapper.find("button"), [0, -1, -1]);
|
||||||
|
|
||||||
|
// focus on 2nd button and test it is the only active one
|
||||||
|
wrapper.find("button").at(2).simulate("focus");
|
||||||
|
wrapper.update();
|
||||||
|
checkTabIndexes(wrapper.find("button"), [-1, -1, 0]);
|
||||||
|
|
||||||
|
// focus on 1st button and test it is the only active one
|
||||||
|
wrapper.find("button").at(1).simulate("focus");
|
||||||
|
wrapper.update();
|
||||||
|
checkTabIndexes(wrapper.find("button"), [-1, 0, -1]);
|
||||||
|
|
||||||
|
// check that the active button does not change even on an explicit blur event
|
||||||
|
wrapper.find("button").at(1).simulate("blur");
|
||||||
|
wrapper.update();
|
||||||
|
checkTabIndexes(wrapper.find("button"), [-1, 0, -1]);
|
||||||
|
|
||||||
|
// update the children, it should remain on the same button
|
||||||
|
wrapper.setProps({
|
||||||
|
children: () => [button1, button4, button2, button3],
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
checkTabIndexes(wrapper.find("button"), [-1, -1, 0, -1]);
|
||||||
|
|
||||||
|
// update the children, remove the active button, it should move to the next one
|
||||||
|
wrapper.setProps({
|
||||||
|
children: () => [button1, button4, button3],
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
checkTabIndexes(wrapper.find("button"), [-1, -1, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => {
|
||||||
|
const wrapper = mount(<RovingTabIndexProvider>
|
||||||
|
{() => <React.Fragment>
|
||||||
|
{ button1 }
|
||||||
|
{ button2 }
|
||||||
|
<RovingTabIndexWrapper>
|
||||||
|
{({onFocus, isActive, ref}) =>
|
||||||
|
<button onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref}>.</button>
|
||||||
|
}
|
||||||
|
</RovingTabIndexWrapper>
|
||||||
|
</React.Fragment>}
|
||||||
|
</RovingTabIndexProvider>);
|
||||||
|
|
||||||
|
// should begin with 0th being active
|
||||||
|
checkTabIndexes(wrapper.find("button"), [0, -1, -1]);
|
||||||
|
|
||||||
|
// focus on 2nd button and test it is the only active one
|
||||||
|
wrapper.find("button").at(2).simulate("focus");
|
||||||
|
wrapper.update();
|
||||||
|
checkTabIndexes(wrapper.find("button"), [-1, -1, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
83
test/components/views/rooms/SendMessageComposer-test.js
Normal file
83
test/components/views/rooms/SendMessageComposer-test.js
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
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 RoomViewStore from "../../../../src/stores/RoomViewStore";
|
||||||
|
import {createMessageContent} from "../../../../src/components/views/rooms/SendMessageComposer";
|
||||||
|
import EditorModel from "../../../../src/editor/model";
|
||||||
|
import {createPartCreator, createRenderer} from "../../../editor/mock";
|
||||||
|
|
||||||
|
jest.mock("../../../../src/stores/RoomViewStore");
|
||||||
|
|
||||||
|
describe('<SendMessageComposer/>', () => {
|
||||||
|
describe("createMessageContent", () => {
|
||||||
|
RoomViewStore.getQuotingEvent.mockReturnValue(false);
|
||||||
|
const permalinkCreator = jest.fn();
|
||||||
|
|
||||||
|
it("sends plaintext messages correctly", () => {
|
||||||
|
const model = new EditorModel([], createPartCreator(), createRenderer());
|
||||||
|
model.update("hello world", "insertText", {offset: 11, atNodeEnd: true});
|
||||||
|
|
||||||
|
const content = createMessageContent(model, permalinkCreator);
|
||||||
|
|
||||||
|
expect(content).toEqual({
|
||||||
|
body: "hello world",
|
||||||
|
msgtype: "m.text",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends markdown messages correctly", () => {
|
||||||
|
const model = new EditorModel([], createPartCreator(), createRenderer());
|
||||||
|
model.update("hello *world*", "insertText", {offset: 13, atNodeEnd: true});
|
||||||
|
|
||||||
|
const content = createMessageContent(model, permalinkCreator);
|
||||||
|
|
||||||
|
expect(content).toEqual({
|
||||||
|
body: "hello *world*",
|
||||||
|
msgtype: "m.text",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "hello <em>world</em>",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips /me from messages and marks them as m.emote accordingly", () => {
|
||||||
|
const model = new EditorModel([], createPartCreator(), createRenderer());
|
||||||
|
model.update("/me blinks __quickly__", "insertText", {offset: 22, atNodeEnd: true});
|
||||||
|
|
||||||
|
const content = createMessageContent(model, permalinkCreator);
|
||||||
|
|
||||||
|
expect(content).toEqual({
|
||||||
|
body: "blinks __quickly__",
|
||||||
|
msgtype: "m.emote",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
formatted_body: "blinks <strong>quickly</strong>",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows sending double-slash escaped slash commands correctly", () => {
|
||||||
|
const model = new EditorModel([], createPartCreator(), createRenderer());
|
||||||
|
model.update("//dev/null is my favourite place", "insertText", {offset: 32, atNodeEnd: true});
|
||||||
|
|
||||||
|
const content = createMessageContent(model, permalinkCreator);
|
||||||
|
|
||||||
|
expect(content).toEqual({
|
||||||
|
body: "/dev/null is my favourite place",
|
||||||
|
msgtype: "m.text",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
@ -67,3 +67,13 @@ export function createPartCreator(completions = []) {
|
||||||
};
|
};
|
||||||
return new PartCreator(new MockRoom(), new MockClient(), autoCompleteCreator);
|
return new PartCreator(new MockRoom(), new MockClient(), autoCompleteCreator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createRenderer() {
|
||||||
|
const render = (c) => {
|
||||||
|
render.caret = c;
|
||||||
|
render.count += 1;
|
||||||
|
};
|
||||||
|
render.count = 0;
|
||||||
|
render.caret = null;
|
||||||
|
return render;
|
||||||
|
}
|
||||||
|
|
|
@ -15,17 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import EditorModel from "../../src/editor/model";
|
import EditorModel from "../../src/editor/model";
|
||||||
import {createPartCreator} from "./mock";
|
import {createPartCreator, createRenderer} from "./mock";
|
||||||
|
|
||||||
function createRenderer() {
|
|
||||||
const render = (c) => {
|
|
||||||
render.caret = c;
|
|
||||||
render.count += 1;
|
|
||||||
};
|
|
||||||
render.count = 0;
|
|
||||||
render.caret = null;
|
|
||||||
return render;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('editor/model', function() {
|
describe('editor/model', function() {
|
||||||
describe('plain text manipulation', function() {
|
describe('plain text manipulation', function() {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue