diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml index a8ce1273fb..f5f63b647a 100644 --- a/.buildkite/pipeline.yaml +++ b/.buildkite/pipeline.yaml @@ -76,6 +76,11 @@ steps: - docker#v3.0.1: image: "matrixdotorg/riotweb-ci-e2etests-env:latest" 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" agents: @@ -83,27 +88,16 @@ steps: # webpack loves to gorge itself on resources. queue: "medium" 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 - "chmod +x ./scripts/ci/*.sh" - "chmod +x ./scripts/*" - - "echo '--- Installing Dependencies'" - - "./scripts/ci/install-deps.sh" - - "echo '--- Running initial build steps'" - - "yarn build" - "echo '+++ Running Tests'" - "./scripts/ci/riot-unit-tests.sh" - env: - CHROME_BIN: "/usr/bin/google-chrome-stable" plugins: - docker#v3.0.1: image: "node:10" propagate-environment: true + workdir: "/workdir/matrix-react-sdk" - label: "🌐 i18n" command: diff --git a/package.json b/package.json index aa2cf8bf8b..8f49eed4b5 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "typings": "./lib/index.d.ts", "matrix_src_main": "./src/index.js", "scripts": { - "prepublish": "yarn build", + "prepare": "yarn build", "i18n": "matrix-gen-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 --git a/res/css/structures/_GroupView.scss b/res/css/structures/_GroupView.scss index 4ec53a3c9a..517b8b1922 100644 --- a/res/css/structures/_GroupView.scss +++ b/res/css/structures/_GroupView.scss @@ -63,7 +63,7 @@ limitations under the License. } .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 { diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index 5634a97c53..5b5c49f357 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -51,8 +51,8 @@ limitations under the License. &.mx_Toast_hasIcon { &::after { content: ""; - width: 21px; - height: 20px; + width: 22px; + height: 22px; grid-column: 1; grid-row: 1; mask-size: 100%; diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index fbac1e932a..d292c729dd 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -367,6 +367,11 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { opacity: 1; } +.mx_EventTile_e2eIcon_unknown { + background-image: url('$(res)/img/e2e/warning.svg'); + opacity: 1; +} + .mx_EventTile_e2eIcon_unencrypted { background-image: url('$(res)/img/e2e/warning.svg'); 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_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; } @@ -427,8 +433,13 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { 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_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; } @@ -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) .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; width: auto; } // 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_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; left: 41px; } diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index 45b9733faa..0d92247735 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -19,7 +19,10 @@ limitations under the License. border-bottom: 1px solid $primary-hairline-color; .mx_E2EIcon { - margin: 0 5px; + margin: 0; + position: absolute; + bottom: 0; + right: -5px; } } @@ -171,6 +174,7 @@ limitations under the License. width: 28px; height: 28px; margin: 0 7px; + position: relative; } .mx_RoomHeader_avatar .mx_BaseAvatar_image { diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index cb1137bb2f..db2c09f6f1 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -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, // 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_RoomTile_menuButton { display: block; diff --git a/res/img/icons-settings-room.svg b/res/img/icons-settings-room.svg deleted file mode 100644 index 421eefdefa..0000000000 --- a/res/img/icons-settings-room.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 288fb3cadc..c868c81549 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -224,6 +224,7 @@ $copy-button-url: "$(res)/img/icon_copy_message.svg"; // e2e $e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color +$e2e-unknown-color: #e8bf37; $e2e-unverified-color: #e8bf37; $e2e-warning-color: #ba6363; diff --git a/scripts/ci/build.sh b/scripts/ci/build.sh deleted file mode 100755 index 0b1fa23093..0000000000 --- a/scripts/ci/build.sh +++ /dev/null @@ -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 diff --git a/scripts/ci/end-to-end-tests.sh b/scripts/ci/end-to-end-tests.sh index a592888292..9bdb512940 100755 --- a/scripts/ci/end-to-end-tests.sh +++ b/scripts/ci/end-to-end-tests.sh @@ -21,15 +21,16 @@ handle_error() { trap 'handle_error' ERR -RIOT_WEB_DIR=riot-web -REACT_SDK_DIR=`pwd` - 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 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 # CHROME_PATH=$(which google-chrome-stable) ./run.sh echo "--- Install synapse & other dependencies" diff --git a/scripts/ci/layered-riot-web.sh b/scripts/ci/layered-riot-web.sh new file mode 100644 index 0000000000..f58794b451 --- /dev/null +++ b/scripts/ci/layered-riot-web.sh @@ -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 diff --git a/scripts/ci/riot-unit-tests.sh b/scripts/ci/riot-unit-tests.sh index 215af13030..337c0fe6c3 100755 --- a/scripts/ci/riot-unit-tests.sh +++ b/scripts/ci/riot-unit-tests.sh @@ -6,9 +6,7 @@ set -ev -RIOT_WEB_DIR=riot-web - -scripts/ci/build.sh -pushd "$RIOT_WEB_DIR" +scripts/ci/layered-riot-web.sh +cd ../riot-web +yarn build:genfiles # so the tests can run. Faster version of `build` yarn test -popd diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh index f82752bfc5..0142305797 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -17,7 +17,7 @@ clone() { if [ -n "$branch" ] then 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 } diff --git a/src/DeviceListener.js b/src/DeviceListener.js index 9ae6a62ab1..a4c5785db4 100644 --- a/src/DeviceListener.js +++ b/src/DeviceListener.js @@ -75,7 +75,7 @@ export default class DeviceListener { if (device.deviceId == cli.deviceId) continue; 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)); } else { ToastStore.sharedInstance().addOrReplaceToast({ diff --git a/src/Markdown.js b/src/Markdown.js index acfea52100..437ceec88b 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -91,7 +91,7 @@ export default class Markdown { return true; } - toHTML() { + toHTML({ externalLinks = false } = {}) { const renderer = new commonmark.HtmlRenderer({ 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; diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 20b8ba76da..2eb34576ac 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -81,6 +81,8 @@ class Command { } 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); } @@ -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} 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. * 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 // IRC-style commands input = input.replace(/\s+$/, ''); if (input[0] !== '/') return null; // not a command - const bits = input.match(/^(\S+?)( +((.|\n)*))?$/); + const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/); let cmd; let args; if (bits) { cmd = bits[1].substring(1).toLowerCase(); - args = bits[3]; + args = bits[2]; } else { cmd = input; } @@ -932,11 +934,6 @@ export function processCommandInput(roomId, input) { cmd = aliases[cmd]; } if (CommandMap[cmd]) { - // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` - if (!CommandMap[cmd].runFn) return null; - - return CommandMap[cmd].run(roomId, args); - } else { - return reject(_t('Unrecognised command:') + ' ' + input); + return () => CommandMap[cmd].run(roomId, args); } } diff --git a/src/accessibility/RovingTabIndex.js b/src/accessibility/RovingTabIndex.js new file mode 100644 index 0000000000..b481f08fe2 --- /dev/null +++ b/src/accessibility/RovingTabIndex.js @@ -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 + { children({onKeyDownHandler}) } + ; +}; +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}); +}; + diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index 8a7d10e5b5..f5e0bca67e 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -129,9 +129,6 @@ const LeftPanel = createReactClass({ if (!this.focusedElement) return; switch (ev.key) { - case Key.TAB: - this._onMoveFocus(ev, ev.shiftKey); - break; case Key.ARROW_UP: this._onMoveFocus(ev, true, true); break; diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index c2a644287d..600b418fe0 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -31,6 +31,7 @@ import PropTypes from 'prop-types'; import RoomTile from "../views/rooms/RoomTile"; import LazyRenderList from "../views/elements/LazyRenderList"; import {_t} from "../../languageHandler"; +import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex"; // turn this on for drop & drag console debugging galore const debug = false; @@ -141,10 +142,6 @@ export default class RoomSubList extends React.PureComponent { onHeaderKeyDown = (ev) => { 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: // On ARROW_LEFT collapse the room sublist if (!this.state.hidden && !this.props.forceExpand) { @@ -263,33 +260,6 @@ export default class RoomSubList extends React.PureComponent { const subListNotifCount = subListNotifications.count; 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 = ( - -
- { FormattingUtils.formatCount(subListNotifCount) } -
-
- ); - } else if (this.props.isInvite && this.props.list.length) { - // no notifications but highlight anyway because this is an invite badge - badge = ( - -
- { this.props.list.length } -
-
- ); - } - } - // When collapsed, allow a long hover on the header to show user // the full tag name and room count let title; @@ -305,17 +275,6 @@ export default class RoomSubList extends React.PureComponent { ; } - let addRoomButton; - if (this.props.onAddRoom) { - addRoomButton = ( - - ); - } - const len = this.props.list.length + this.props.extraTiles.length; let chevron; if (len) { @@ -327,25 +286,81 @@ export default class RoomSubList extends React.PureComponent { chevron = (
); } - return ( -
- - { chevron } - {this.props.label} - { incomingCall } - - { badge } - { addRoomButton } -
- ); + return + {({onFocus, isActive, ref}) => { + const tabIndex = isActive ? 0 : -1; + + 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 = ( + +
+ { FormattingUtils.formatCount(subListNotifCount) } +
+
+ ); + } else if (this.props.isInvite && this.props.list.length) { + // no notifications but highlight anyway because this is an invite badge + badge = ( + +
+ { this.props.list.length } +
+
+ ); + } + } + + let addRoomButton; + if (this.props.onAddRoom) { + addRoomButton = ( + + ); + } + + return ( +
+ + { chevron } + {this.props.label} + { incomingCall } + + { badge } + { addRoomButton } +
+ ); + } } +
; } checkOverflow = () => { diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index 3be2f65dc5..873efb64c2 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -133,9 +133,11 @@ export default createReactClass({ return null; } const clearButton = (!this.state.blurred || this.state.searchTerm) ? - ( {this._clearSearch("button"); } }> + ( {this._clearSearch("button"); } }> ) : undefined; // show a shorter placeholder when blurred, if requested diff --git a/src/components/views/context_menus/RoomTileContextMenu.js b/src/components/views/context_menus/RoomTileContextMenu.js index 6e2bd8ebf5..2d8dec29c7 100644 --- a/src/components/views/context_menus/RoomTileContextMenu.js +++ b/src/components/views/context_menus/RoomTileContextMenu.js @@ -306,7 +306,7 @@ export default createReactClass({ return (
- + { _t('Settings') }
diff --git a/src/components/views/dialogs/ReportEventDialog.js b/src/components/views/dialogs/ReportEventDialog.js index e77bb0693b..99853582dd 100644 --- a/src/components/views/dialogs/ReportEventDialog.js +++ b/src/components/views/dialogs/ReportEventDialog.js @@ -20,6 +20,8 @@ import { _t } from '../../../languageHandler'; import PropTypes from "prop-types"; import {MatrixEvent} from "matrix-js-sdk"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import SdkConfig from '../../../SdkConfig'; +import Markdown from '../../../Markdown'; /* * 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 =

; + } + return ( - + {adminMessage} + // XXX: this is a workaround for Firefox giving this div a tabstop :( [tabIndex] + const label =

{ groupName }
; @@ -137,16 +139,6 @@ export default createReactClass({ }); const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!'; - const badge = ( - - { badgeContent } - - ); let tooltip; if (this.props.collapsed && this.state.hover) { @@ -171,22 +163,37 @@ export default createReactClass({ } return - -
- { av } -
-
- { label } - { badge } -
- { tooltip } -
+ + {({onFocus, isActive, ref}) => + +
+ { av } +
+
+ { label } + + { badgeContent } + +
+ { tooltip } +
+ } +
{ contextMenu }
; diff --git a/src/components/views/right_panel/UserInfo.js b/src/components/views/right_panel/UserInfo.js index 5f7de42368..b08f07ace4 100644 --- a/src/components/views/right_panel/UserInfo.js +++ b/src/components/views/right_panel/UserInfo.js @@ -64,10 +64,17 @@ const _getE2EStatus = (cli, userId, devices) => { const hasUnverifiedDevice = devices.some((device) => device.isUnverified()); return hasUnverifiedDevice ? "warning" : "verified"; } + const isMe = userId === cli.getUserId(); const userVerified = cli.checkUserTrust(userId).isCrossSigningVerified(); const allDevicesVerified = devices.every(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) { return userVerified ? "verified" : "normal"; @@ -128,19 +135,28 @@ function verifyUser(user) { function DeviceItem({userId, device}) { const cli = useContext(MatrixClientContext); + const isMe = userId === cli.getUserId(); 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", { - mx_UserInfo_device_verified: deviceTrust.isVerified(), - mx_UserInfo_device_unverified: !deviceTrust.isVerified(), + mx_UserInfo_device_verified: isVerified, + mx_UserInfo_device_unverified: !isVerified, }); const iconClasses = classNames("mx_E2EIcon", { - mx_E2EIcon_verified: deviceTrust.isVerified(), - mx_E2EIcon_warning: !deviceTrust.isVerified(), + mx_E2EIcon_verified: isVerified, + mx_E2EIcon_warning: !isVerified, }); const onDeviceClick = () => { - if (!deviceTrust.isVerified()) { + if (!isVerified) { verifyDevice(userId, device); } }; @@ -148,7 +164,7 @@ function DeviceItem({userId, device}) { const deviceName = device.ambiguous ? (device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" : device.getDisplayName(); - const trustedLabel = deviceTrust.isVerified() ? _t("Trusted") : _t("Not trusted"); + const trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted"); return (
{deviceName}
@@ -169,6 +185,7 @@ function DevicesSection({devices, userId, loading}) { if (devices === null) { return _t("Unable to load device list"); } + const isMe = userId === cli.getUserId(); const deviceTrusts = devices.map(d => cli.checkDeviceTrust(userId, d.deviceId)); const unverifiedDevices = []; @@ -177,8 +194,16 @@ function DevicesSection({devices, userId, loading}) { for (let i = 0; i < devices.length; ++i) { const device = devices[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); } else { unverifiedDevices.push(device); @@ -1277,18 +1302,24 @@ const UserInfo = ({user, groupId, roomId, onClose}) => { 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(); let verifyButton; - if (!userVerified && !isMe) { + if (isRoomEncrypted && !userVerified && !isMe) { verifyButton = verifyUser(user)}> {_t("Verify")} ; } - const devicesSection = ; + let devicesSection; + if (isRoomEncrypted) { + devicesSection = ; + } const securitySection = (
diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 94904242c3..73c3d961ee 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -107,8 +107,9 @@ export default class BasicMessageEditor extends React.Component { }); const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text); if (emoticonMatch) { - const query = emoticonMatch[1].toLowerCase().replace("-", ""); - const data = EMOTICON_TO_EMOJI.get(query); + const query = emoticonMatch[1].replace("-", ""); + // 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) { const {partCreator} = model; diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index dce4dc8a93..634b77c9e1 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -66,6 +66,13 @@ const stateEventTileTypes = { '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 for (const evType of ALL_RULE_TYPES) { stateEventTileTypes[evType] = 'messages.TextualEvent'; @@ -235,6 +242,7 @@ export default createReactClass({ this._suppressReadReceiptAnimation = false; const client = this.context; client.on("deviceVerificationChanged", this.onDeviceVerificationChanged); + client.on("userTrustStatusChanged", this.onUserVerificationChanged); this.props.mxEvent.on("Event.decrypted", this._onDecrypted); if (this.props.showReactions) { this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated); @@ -260,6 +268,7 @@ export default createReactClass({ componentWillUnmount: function() { const client = this.context; client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); + client.removeListener("userTrustStatusChanged", this.onUserVerificationChanged); this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted); if (this.props.showReactions) { 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) { if (!mxEvent.isEncrypted()) { return; } + // If we directly trust the device, short-circuit here 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({ - verified: verified, - }, () => { - // Decryption may have caused a change in size - this.props.onHeightChanged(); - }); + verified: eventSenderTrust.isVerified() ? E2E_STATE.VERIFIED : E2E_STATE.WARNING, + }, this.props.onHeightChanged); // Decryption may have caused a change in size }, _propsEqual: function(objA, objB) { @@ -473,8 +520,12 @@ export default createReactClass({ // event is encrypted, display padlock corresponding to whether or not it is verified 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 + } else if (this.state.verified === E2E_STATE.UNKNOWN) { + return (); } else { return (); } @@ -604,8 +655,9 @@ export default createReactClass({ mx_EventTile_last: this.props.last, mx_EventTile_contextual: this.props.contextual, mx_EventTile_actionBarFocused: this.state.actionBarFocused, - mx_EventTile_verified: !isBubbleMessage && this.state.verified === true, - mx_EventTile_unverified: !isBubbleMessage && this.state.verified === false, + mx_EventTile_verified: !isBubbleMessage && this.state.verified === E2E_STATE.VERIFIED, + 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_emote: msgtype === 'm.emote', mx_EventTile_redacted: isRedacted, @@ -901,6 +953,12 @@ function E2ePadlockUnencrypted(props) { ); } +function E2ePadlockUnknown(props) { + return ( + + ); +} + class E2ePadlock extends React.Component { static propTypes = { icon: PropTypes.string.isRequired, diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 09f3fd489f..15f0daa200 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -310,8 +310,7 @@ export default createReactClass({ return (
-
{ roomAvatar }
- { e2eIcon } +
{ roomAvatar }{ e2eIcon }
{ privateIcon } { name } { topicElement } diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 5c12b027a4..ee3100b535 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -39,6 +39,7 @@ import * as sdk from "../../../index"; import * as Receipt from "../../../utils/Receipt"; import {Resizer} from '../../../resizer'; import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2'; +import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex"; const HIDE_CONFERENCE_CHANS = true; 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 {resizeNotifier, collapsed, searchFilter, ConferenceHandler, ...props} = this.props; // eslint-disable-line + const {resizeNotifier, collapsed, searchFilter, ConferenceHandler, onKeyDown, ...props} = this.props; // eslint-disable-line return ( -
- { subListComponents } -
+ + {({onKeyDownHandler}) =>
+ { subListComponents } +
} +
); }, }); diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 8c67be3b2a..f4f5fa10fc 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -32,6 +32,7 @@ import ActiveRoomObserver from '../../../ActiveRoomObserver'; import RoomViewStore from '../../../stores/RoomViewStore'; import SettingsStore from "../../../settings/SettingsStore"; import {_t} from "../../../languageHandler"; +import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex"; export default createReactClass({ displayName: 'RoomTile', @@ -352,7 +353,8 @@ export default createReactClass({ }); subtextLabel = subtext ? { subtext } : null; - label =
{ name }
; + // XXX: this is a workaround for Firefox giving this div a tabstop :( [tabIndex] + label =
{ name }
; } else if (this.state.hover) { const Tooltip = sdk.getComponent("elements.Tooltip"); tooltip = ; @@ -432,36 +434,42 @@ export default createReactClass({ } return - -
-
- - { dmIndicator } -
-
- { privateIcon } -
-
- { label } - { subtextLabel } -
- { dmOnline } - { contextMenuButton } - { badge } -
- { /* { incomingCallBox } */ } - { tooltip } -
+ + {({onFocus, isActive, ref}) => + +
+
+ + { dmIndicator } +
+
+ { privateIcon } +
+
+ { label } + { subtextLabel } +
+ { dmOnline } + { contextMenuButton } + { badge } +
+ { /* { incomingCallBox } */ } + { tooltip } +
+ } +
{ contextMenu }
; diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index c11d940331..63e58bf738 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -24,6 +24,8 @@ import { containsEmote, stripEmoteCommand, unescapeMessage, + startsWith, + stripPrefix, } from '../../../editor/serialize'; import {CommandPartCreator} from '../../../editor/parts'; import BasicMessageComposer from "./BasicMessageComposer"; @@ -33,7 +35,7 @@ import ReplyThread from "../elements/ReplyThread"; import {parseEvent} from '../../../editor/deserialize'; import {findEditableEvent} from '../../../utils/EventUtils'; import SendHistoryManager from "../../../SendHistoryManager"; -import {processCommandInput} from '../../../SlashCommands'; +import {getCommand} from '../../../SlashCommands'; import * as sdk from '../../../index'; import Modal from '../../../Modal'; 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); if (isEmote) { model = stripEmoteCommand(model); } + if (startsWith(model, "//")) { + model = stripPrefix(model, "/"); + } model = unescapeMessage(model); const repliedToEvent = RoomViewStore.getQuotingEvent(); @@ -175,20 +181,21 @@ export default class SendMessageComposer extends React.Component { const parts = this.model.parts; const firstPart = parts[0]; if (firstPart) { - if (firstPart.type === "command") { + if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) { return true; } // be extra resilient when somehow the AutocompleteWrapperModel or // CommandPartCreator fails to insert a command part, so we don't send // 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 false; } - async _runSlashCommand() { + _getSlashCommand() { const commandText = this.model.parts.reduce((text, part) => { // use mxid to textify user pills in a command if (part.type === "user-pill") { @@ -196,50 +203,86 @@ export default class SendMessageComposer extends React.Component { } return text + part.text; }, ""); - const cmd = processCommandInput(this.props.room.roomId, commandText); + return [getCommand(this.props.room.roomId, commandText), commandText]; + } - if (cmd) { - let error = cmd.error; - if (cmd.promise) { - try { - await cmd.promise; - } catch (err) { - error = err; - } + async _runSlashCommand(fn) { + const cmd = fn(); + let error = cmd.error; + if (cmd.promise) { + try { + await cmd.promise; + } catch (err) { + error = err; } - if (error) { - console.error("Command failure: %s", error); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - // assume the error is a server error when the command is async - const isServerError = !!cmd.promise; - const title = isServerError ? _td("Server error") : _td("Command error"); + } + if (error) { + console.error("Command failure: %s", error); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + // assume the error is a server error when the command is async + const isServerError = !!cmd.promise; + const title = isServerError ? _td("Server error") : _td("Command error"); - let errText; - if (typeof error === 'string') { - errText = error; - } else if (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, - }); + let errText; + if (typeof error === 'string') { + errText = error; + } else if (error.message) { + errText = error.message; } 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) { return; } + + let shouldSend = true; + if (!containsEmote(this.model) && this._isSlashCommand()) { - this._runSlashCommand(); - } else { + const [cmd, commandText] = this._getSlashCommand(); + 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:
+

+ { _t("Unrecognised command: %(commandText)s", {commandText}) } +

+

+ { _t("You can use /help to list available commands. " + + "Did you mean to send this as a message?", {}, { + code: t => { t }, + }) } +

+

+ { _t("Hint: Begin your message with // to start it with a slash.", {}, { + code: t => { t }, + }) } +

+
, + 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 {roomId} = this.props.room; const content = createMessageContent(this.model, this.props.permalinkCreator); @@ -253,6 +296,7 @@ export default class SendMessageComposer extends React.Component { }); } } + this.sendHistoryManager.save(this.model); // clear composer this.model.reset([]); @@ -326,7 +370,8 @@ export default class SendMessageComposer extends React.Component { member.rawDisplayName : userId; const caret = this._editorRef.getCaret(); 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); model.transform(() => { const addedLen = model.insert(parts, position); diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.js index 49046cd051..f7d3d62b4f 100644 --- a/src/components/views/settings/CrossSigningPanel.js +++ b/src/components/views/settings/CrossSigningPanel.js @@ -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"); 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 = { 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("userTrustStatusChanged", this.onStatusChanged); cli.on("crossSigning.keysChanged", this.onStatusChanged); + this._getUpdatedStatus(); } componentWillUnmount() { @@ -52,12 +55,12 @@ export default class CrossSigningPanel extends React.PureComponent { onAccountData = (event) => { const type = event.getType(); if (type.startsWith("m.cross_signing") || type.startsWith("m.secret_storage")) { - this.setState(this._getUpdatedStatus()); + this._getUpdatedStatus(); } }; onStatusChanged = () => { - this.setState(this._getUpdatedStatus()); + this._getUpdatedStatus(); }; async _getUpdatedStatus() { @@ -69,11 +72,11 @@ export default class CrossSigningPanel extends React.PureComponent { const crossSigningPrivateKeysInStorage = await crossSigning.isStoredInSecretStorage(secretStorage); const secretStorageKeyInAccount = await secretStorage.hasKey(); - return { + this.setState({ crossSigningPublicKeysOnDevice, crossSigningPrivateKeysInStorage, secretStorageKeyInAccount, - }; + }); } /** @@ -93,7 +96,7 @@ export default class CrossSigningPanel extends React.PureComponent { console.error("Error bootstrapping secret storage", e); } if (this._unmounted) return; - this.setState(this._getUpdatedStatus()); + this._getUpdatedStatus(); } render() { diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.js b/src/components/views/settings/tabs/user/HelpUserSettingsTab.js index ab71de86b9..a245c7c7b9 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.js @@ -21,15 +21,10 @@ import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import AccessibleButton from "../../../elements/AccessibleButton"; import SdkConfig from "../../../../../SdkConfig"; import createRoom from "../../../../../createRoom"; -import packageJson from "../../../../../../package.json"; import Modal from "../../../../../Modal"; import * as sdk from "../../../../../"; 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 || ''; - // 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 ghVersionLabel = function(repo, token='') { @@ -188,9 +183,6 @@ export default class HelpUserSettingsTab extends React.Component { ); } - const reactSdkVersion = REACT_SDK_VERSION !== '' - ? ghVersionLabel('matrix-org/matrix-react-sdk', REACT_SDK_VERSION) - : REACT_SDK_VERSION; const vectorVersion = this.state.vectorVersion ? ghVersionLabel('vector-im/riot-web', this.state.vectorVersion) : 'unknown'; @@ -243,7 +235,6 @@ export default class HelpUserSettingsTab extends React.Component {
{_t("Versions")}
- {_t("matrix-react-sdk version:")} {reactSdkVersion}
{_t("riot-web version:")} {vectorVersion}
{_t("olm version:")} {olmVersion}
{updateButton} diff --git a/src/editor/serialize.js b/src/editor/serialize.js index a55eed97da..ba380f2809 100644 --- a/src/editor/serialize.js +++ b/src/editor/serialize.js @@ -61,18 +61,26 @@ export function textSerialize(model) { } export function containsEmote(model) { + return startsWith(model, "/me "); +} + +export function startsWith(model, prefix) { const firstPart = model.parts[0]; // part type will be "plain" while editing, // and "command" while composing a message. return firstPart && (firstPart.type === "plain" || firstPart.type === "command") && - firstPart.text.startsWith("/me "); + firstPart.text.startsWith(prefix); } export function stripEmoteCommand(model) { // trim "/me " + return stripPrefix(model, "/me "); +} + +export function stripPrefix(model, prefix) { model = model.clone(); - model.removeText({index: 0, offset: 0}, 4); + model.removeText({index: 0, offset: 0}, prefix.length); return model; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 22c09b5ae6..62373e8092 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -200,7 +200,6 @@ "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", "Displays list of commands with usages and descriptions": "Displays list of commands with usages and descriptions", - "Unrecognised command:": "Unrecognised command:", "Reason": "Reason", "%(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.", @@ -688,7 +687,6 @@ "Clear cache and reload": "Clear cache and reload", "FAQ": "FAQ", "Versions": "Versions", - "matrix-react-sdk version:": "matrix-react-sdk version:", "riot-web version:": "riot-web version:", "olm version:": "olm version:", "Homeserver is": "Homeserver is", @@ -905,6 +903,7 @@ "This message cannot be decrypted": "This message cannot be decrypted", "Encrypted by an unverified device": "Encrypted by an unverified device", "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", "Scroll to bottom of page": "Scroll to bottom of page", "Close preview": "Close preview", @@ -1077,6 +1076,11 @@ "Server error": "Server error", "Command error": "Command error", "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 /help to list available commands. Did you mean to send this as a message?": "You can use /help to list available commands. Did you mean to send this as a message?", + "Hint: Begin your message with // to start it with a slash.": "Hint: Begin your message with // to start it with a slash.", + "Send as message": "Send as message", "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", "Add some now": "Add some now", diff --git a/test/accessibility/RovingTabIndex-test.js b/test/accessibility/RovingTabIndex-test.js new file mode 100644 index 0000000000..8be4a2976c --- /dev/null +++ b/test/accessibility/RovingTabIndex-test.js @@ -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 ; +const button2 = ; +const button3 = ; +const button4 = ; + +describe("RovingTabIndex", () => { + it("RovingTabIndexProvider renders children as expected", () => { + const wrapper = mount( + {() =>
Test
} +
); + expect(wrapper.text()).toBe("Test"); + expect(wrapper.html()).toBe('
Test
'); + }); + + it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => { + const wrapper = mount( + {() => + { button1 } + { button2 } + { button3 } + } + ); + + // 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( + {() => + { button1 } + { button2 } + + {({onFocus, isActive, ref}) => + + } + + } + ); + + // 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]); + }); +}); + + diff --git a/test/components/views/rooms/SendMessageComposer-test.js b/test/components/views/rooms/SendMessageComposer-test.js new file mode 100644 index 0000000000..d5a143a1fb --- /dev/null +++ b/test/components/views/rooms/SendMessageComposer-test.js @@ -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('', () => { + 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 world", + }); + }); + + 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 quickly", + }); + }); + + 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", + }); + }); + }); +}); + + diff --git a/test/editor/mock.js b/test/editor/mock.js index bb1a51d14b..6de65cf23d 100644 --- a/test/editor/mock.js +++ b/test/editor/mock.js @@ -67,3 +67,13 @@ export function createPartCreator(completions = []) { }; 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; +} diff --git a/test/editor/model-test.js b/test/editor/model-test.js index 826dde3d68..2a3584d508 100644 --- a/test/editor/model-test.js +++ b/test/editor/model-test.js @@ -15,17 +15,7 @@ limitations under the License. */ import EditorModel from "../../src/editor/model"; -import {createPartCreator} from "./mock"; - -function createRenderer() { - const render = (c) => { - render.caret = c; - render.count += 1; - }; - render.count = 0; - render.caret = null; - return render; -} +import {createPartCreator, createRenderer} from "./mock"; describe('editor/model', function() { describe('plain text manipulation', function() {