mirror of https://github.com/vector-im/riot-web
				
				
				
			Merge remote-tracking branch 'origin/develop' into dbkr/bootstrap_visuals_1
						commit
						10db79bb93
					
				|  | @ -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: | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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%; | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
										
											
												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-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color | ||||
| $e2e-unknown-color: #e8bf37; | ||||
| $e2e-unverified-color: #e8bf37; | ||||
| $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 | ||||
| 
 | ||||
| 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" | ||||
|  |  | |||
|  | @ -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 | ||||
| 
 | ||||
| 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 | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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({ | ||||
|  |  | |||
|  | @ -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; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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; | ||||
| 
 | ||||
|         switch (ev.key) { | ||||
|             case Key.TAB: | ||||
|                 this._onMoveFocus(ev, ev.shiftKey); | ||||
|                 break; | ||||
|             case Key.ARROW_UP: | ||||
|                 this._onMoveFocus(ev, true, true); | ||||
|                 break; | ||||
|  |  | |||
|  | @ -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 = ( | ||||
|                     <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
 | ||||
|         // the full tag name and room count
 | ||||
|         let title; | ||||
|  | @ -305,17 +275,6 @@ export default class RoomSubList extends React.PureComponent { | |||
|                 <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; | ||||
|         let chevron; | ||||
|         if (len) { | ||||
|  | @ -327,25 +286,81 @@ export default class RoomSubList extends React.PureComponent { | |||
|             chevron = (<div className={chevronClasses} />); | ||||
|         } | ||||
| 
 | ||||
|         return ( | ||||
|             <div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}> | ||||
|                 <AccessibleButton | ||||
|                     onClick={this.onClick} | ||||
|                     className="mx_RoomSubList_label" | ||||
|                     tabIndex={0} | ||||
|                     aria-expanded={!isCollapsed} | ||||
|                     inputRef={this._headerButton} | ||||
|                     role="treeitem" | ||||
|                     aria-level="1" | ||||
|                 > | ||||
|                     { chevron } | ||||
|                     <span>{this.props.label}</span> | ||||
|                     { incomingCall } | ||||
|                 </AccessibleButton> | ||||
|                 { badge } | ||||
|                 { addRoomButton } | ||||
|             </div> | ||||
|         ); | ||||
|         return <RovingTabIndexWrapper inputRef={this._headerButton}> | ||||
|             {({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 = ( | ||||
|                             <AccessibleButton | ||||
|                                 tabIndex={tabIndex} | ||||
|                                 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 | ||||
|                                 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 = () => { | ||||
|  |  | |||
|  | @ -133,9 +133,11 @@ export default createReactClass({ | |||
|             return null; | ||||
|         } | ||||
|         const clearButton = (!this.state.blurred || this.state.searchTerm) ? | ||||
|             (<AccessibleButton key="button" | ||||
|                     className="mx_SearchBox_closeButton" | ||||
|                     onClick={ () => {this._clearSearch("button"); } }> | ||||
|             (<AccessibleButton | ||||
|                 key="button" | ||||
|                 tabIndex={-1} | ||||
|                 className="mx_SearchBox_closeButton" | ||||
|                 onClick={ () => {this._clearSearch("button"); } }> | ||||
|             </AccessibleButton>) : undefined; | ||||
| 
 | ||||
|         // show a shorter placeholder when blurred, if requested
 | ||||
|  |  | |||
|  | @ -306,7 +306,7 @@ export default createReactClass({ | |||
|         return ( | ||||
|             <div> | ||||
|                 <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') } | ||||
|                 </MenuItem> | ||||
|             </div> | ||||
|  |  | |||
|  | @ -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 = <p dangerouslySetInnerHTML={{ __html: html }} />; | ||||
|         } | ||||
| 
 | ||||
|         return ( | ||||
|             <BaseDialog | ||||
|                 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.") | ||||
|                         } | ||||
|                     </p> | ||||
| 
 | ||||
|                     {adminMessage} | ||||
|                     <Field | ||||
|                         id="mx_ReportEventDialog_reason" | ||||
|                         className="mx_ReportEventDialog_reason" | ||||
|  |  | |||
|  | @ -26,6 +26,7 @@ import classNames from 'classnames'; | |||
| import {MatrixClientPeg} from "../../../MatrixClientPeg"; | ||||
| import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextMenu"; | ||||
| import MatrixClientContext from "../../../contexts/MatrixClientContext"; | ||||
| import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex"; | ||||
| 
 | ||||
| // XXX this class copies a lot from RoomTile.js
 | ||||
| export default createReactClass({ | ||||
|  | @ -127,7 +128,8 @@ export default createReactClass({ | |||
|             '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 } | ||||
|         </div>; | ||||
| 
 | ||||
|  | @ -137,16 +139,6 @@ export default createReactClass({ | |||
|         }); | ||||
| 
 | ||||
|         const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!'; | ||||
|         const badge = ( | ||||
|             <ContextMenuButton | ||||
|                 className={badgeClasses} | ||||
|                 onClick={this.onContextMenuButtonClick} | ||||
|                 label={_t("Options")} | ||||
|                 isExpanded={isMenuDisplayed} | ||||
|             > | ||||
|                 { badgeContent } | ||||
|             </ContextMenuButton> | ||||
|         ); | ||||
| 
 | ||||
|         let tooltip; | ||||
|         if (this.props.collapsed && this.state.hover) { | ||||
|  | @ -171,22 +163,37 @@ export default createReactClass({ | |||
|         } | ||||
| 
 | ||||
|         return <React.Fragment> | ||||
|             <AccessibleButton | ||||
|                 className={classes} | ||||
|                 onClick={this.onClick} | ||||
|                 onMouseEnter={this.onMouseEnter} | ||||
|                 onMouseLeave={this.onMouseLeave} | ||||
|                 onContextMenu={this.onContextMenu} | ||||
|             > | ||||
|                 <div className="mx_RoomTile_avatar"> | ||||
|                     { av } | ||||
|                 </div> | ||||
|                 <div className="mx_RoomTile_nameContainer"> | ||||
|                     { label } | ||||
|                     { badge } | ||||
|                 </div> | ||||
|                 { tooltip } | ||||
|             </AccessibleButton> | ||||
|             <RovingTabIndexWrapper> | ||||
|                 {({onFocus, isActive, ref}) => | ||||
|                     <AccessibleButton | ||||
|                         onFocus={onFocus} | ||||
|                         tabIndex={isActive ? 0 : -1} | ||||
|                         inputRef={ref} | ||||
|                         className={classes} | ||||
|                         onClick={this.onClick} | ||||
|                         onMouseEnter={this.onMouseEnter} | ||||
|                         onMouseLeave={this.onMouseLeave} | ||||
|                         onContextMenu={this.onContextMenu} | ||||
|                     > | ||||
|                         <div className="mx_RoomTile_avatar"> | ||||
|                             { av } | ||||
|                         </div> | ||||
|                         <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 } | ||||
|         </React.Fragment>; | ||||
|  |  | |||
|  | @ -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 (<AccessibleButton className={classes} onClick={onDeviceClick}> | ||||
|         <div className={iconClasses} /> | ||||
|         <div className="mx_UserInfo_device_name">{deviceName}</div> | ||||
|  | @ -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 = <AccessibleButton className="mx_UserInfo_verify" onClick={() => verifyUser(user)}> | ||||
|             {_t("Verify")} | ||||
|         </AccessibleButton>; | ||||
|     } | ||||
| 
 | ||||
|     const devicesSection = <DevicesSection | ||||
|         loading={devices === undefined} | ||||
|         devices={devices} userId={user.userId} />; | ||||
|     let devicesSection; | ||||
|     if (isRoomEncrypted) { | ||||
|         devicesSection = <DevicesSection | ||||
|             loading={devices === undefined} | ||||
|             devices={devices} userId={user.userId} />; | ||||
|     } | ||||
| 
 | ||||
|     const securitySection = ( | ||||
|         <div className="mx_UserInfo_container"> | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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 (<E2ePadlockUnknown />); | ||||
|             } else { | ||||
|                 return (<E2ePadlockUnverified />); | ||||
|             } | ||||
|  | @ -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 ( | ||||
|         <E2ePadlock title={_t("Encrypted by a deleted device")} icon="unknown" {...props} /> | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| class E2ePadlock extends React.Component { | ||||
|     static propTypes = { | ||||
|         icon: PropTypes.string.isRequired, | ||||
|  |  | |||
|  | @ -310,8 +310,7 @@ export default createReactClass({ | |||
|         return ( | ||||
|             <div className="mx_RoomHeader light-panel"> | ||||
|                 <div className="mx_RoomHeader_wrapper"> | ||||
|                     <div className="mx_RoomHeader_avatar">{ roomAvatar }</div> | ||||
|                     { e2eIcon } | ||||
|                     <div className="mx_RoomHeader_avatar">{ roomAvatar }{ e2eIcon }</div> | ||||
|                     { privateIcon } | ||||
|                     { name } | ||||
|                     { topicElement } | ||||
|  |  | |||
|  | @ -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 ( | ||||
|             <div | ||||
|                 {...props} | ||||
|                 ref={this._collectResizeContainer} | ||||
|                 className="mx_RoomList" | ||||
|                 role="tree" | ||||
|                 aria-label={_t("Rooms")} | ||||
|                 onMouseMove={this.onMouseMove} | ||||
|                 onMouseLeave={this.onMouseLeave} | ||||
|             > | ||||
|                 { subListComponents } | ||||
|             </div> | ||||
|             <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}> | ||||
|                 {({onKeyDownHandler}) => <div | ||||
|                     {...props} | ||||
|                     onKeyDown={onKeyDownHandler} | ||||
|                     ref={this._collectResizeContainer} | ||||
|                     className="mx_RoomList" | ||||
|                     role="tree" | ||||
|                     aria-label={_t("Rooms")} | ||||
|                     onMouseMove={this.onMouseMove} | ||||
|                     onMouseLeave={this.onMouseLeave} | ||||
|                 > | ||||
|                     { subListComponents } | ||||
|                 </div> } | ||||
|             </RovingTabIndexProvider> | ||||
|         ); | ||||
|     }, | ||||
| }); | ||||
|  |  | |||
|  | @ -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 ? <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) { | ||||
|             const Tooltip = sdk.getComponent("elements.Tooltip"); | ||||
|             tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto" />; | ||||
|  | @ -432,36 +434,42 @@ export default createReactClass({ | |||
|         } | ||||
| 
 | ||||
|         return <React.Fragment> | ||||
|             <AccessibleButton | ||||
|                 tabIndex="0" | ||||
|                 className={classes} | ||||
|                 onClick={this.onClick} | ||||
|                 onMouseEnter={this.onMouseEnter} | ||||
|                 onMouseLeave={this.onMouseLeave} | ||||
|                 onContextMenu={this.onContextMenu} | ||||
|                 aria-label={ariaLabel} | ||||
|                 aria-selected={this.state.selected} | ||||
|                 role="treeitem" | ||||
|             > | ||||
|                 <div className={avatarClasses}> | ||||
|                     <div className="mx_RoomTile_avatar_container"> | ||||
|                         <RoomAvatar room={this.props.room} width={24} height={24} /> | ||||
|                         { dmIndicator } | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 { privateIcon } | ||||
|                 <div className="mx_RoomTile_nameContainer"> | ||||
|                     <div className="mx_RoomTile_labelContainer"> | ||||
|                         { label } | ||||
|                         { subtextLabel } | ||||
|                     </div> | ||||
|                     { dmOnline } | ||||
|                     { contextMenuButton } | ||||
|                     { badge } | ||||
|                 </div> | ||||
|                 { /* { incomingCallBox } */ } | ||||
|                 { tooltip } | ||||
|             </AccessibleButton> | ||||
|             <RovingTabIndexWrapper> | ||||
|                 {({onFocus, isActive, ref}) => | ||||
|                     <AccessibleButton | ||||
|                         onFocus={onFocus} | ||||
|                         tabIndex={isActive ? 0 : -1} | ||||
|                         inputRef={ref} | ||||
|                         className={classes} | ||||
|                         onClick={this.onClick} | ||||
|                         onMouseEnter={this.onMouseEnter} | ||||
|                         onMouseLeave={this.onMouseLeave} | ||||
|                         onContextMenu={this.onContextMenu} | ||||
|                         aria-label={ariaLabel} | ||||
|                         aria-selected={this.state.selected} | ||||
|                         role="treeitem" | ||||
|                     > | ||||
|                         <div className={avatarClasses}> | ||||
|                             <div className="mx_RoomTile_avatar_container"> | ||||
|                                 <RoomAvatar room={this.props.room} width={24} height={24} /> | ||||
|                                 { dmIndicator } | ||||
|                             </div> | ||||
|                         </div> | ||||
|                         { privateIcon } | ||||
|                         <div className="mx_RoomTile_nameContainer"> | ||||
|                             <div className="mx_RoomTile_labelContainer"> | ||||
|                                 { label } | ||||
|                                 { subtextLabel } | ||||
|                             </div> | ||||
|                             { dmOnline } | ||||
|                             { contextMenuButton } | ||||
|                             { badge } | ||||
|                         </div> | ||||
|                         { /* { incomingCallBox } */ } | ||||
|                         { tooltip } | ||||
|                     </AccessibleButton> | ||||
|                 } | ||||
|             </RovingTabIndexWrapper> | ||||
| 
 | ||||
|             { contextMenu } | ||||
|         </React.Fragment>; | ||||
|  |  | |||
|  | @ -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: <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 {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); | ||||
|  |  | |||
|  | @ -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() { | ||||
|  |  | |||
|  | @ -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 || '<local>'; | ||||
| 
 | ||||
| // 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 !== '<local>' | ||||
|             ? 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 { | |||
|                 <div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'> | ||||
|                     <span className='mx_SettingsTab_subheading'>{_t("Versions")}</span> | ||||
|                     <div className='mx_SettingsTab_subsectionText'> | ||||
|                         {_t("matrix-react-sdk version:")} {reactSdkVersion}<br /> | ||||
|                         {_t("riot-web version:")} {vectorVersion}<br /> | ||||
|                         {_t("olm version:")} {olmVersion}<br /> | ||||
|                         {updateButton} | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 <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", | ||||
|     "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", | ||||
|     "Add some now": "Add some now", | ||||
|  |  | |||
|  | @ -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]); | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
|  | @ -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); | ||||
| } | ||||
| 
 | ||||
| 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 {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() { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 David Baker
						David Baker