diff --git a/CHANGELOG.md b/CHANGELOG.md index 63702de38b..089bfa73e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +Changes in [2.7.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.7.2) (2020-06-16) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.7.1...v2.7.2) + + * Upgrade to JS SDK 6.2.2 + Changes in [2.7.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.7.1) (2020-06-05) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.7.0...v2.7.1) diff --git a/package.json b/package.json index 966119d1eb..5f9b7dde1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "2.7.1", + "version": "2.7.2", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { diff --git a/res/css/_components.scss b/res/css/_components.scss index 31f319e76f..66eb98ea9d 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -189,6 +189,7 @@ @import "./views/rooms/_RoomSublist2.scss"; @import "./views/rooms/_RoomTile.scss"; @import "./views/rooms/_RoomTile2.scss"; +@import "./views/rooms/_RoomTileIcon.scss"; @import "./views/rooms/_RoomUpgradeWarningBar.scss"; @import "./views/rooms/_SearchBar.scss"; @import "./views/rooms/_SendMessageComposer.scss"; diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index eca50bb639..5cdefa0324 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -131,6 +131,7 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations overflow-y: auto; width: 100%; max-width: 100%; + position: relative; // for sticky headers // Create a flexbox to trick the layout engine display: flex; diff --git a/res/css/structures/auth/_CompleteSecurity.scss b/res/css/structures/auth/_CompleteSecurity.scss index b0462db477..f742be70e4 100644 --- a/res/css/structures/auth/_CompleteSecurity.scss +++ b/res/css/structures/auth/_CompleteSecurity.scss @@ -98,7 +98,3 @@ limitations under the License. } } } - -.mx_CompleteSecurity_resetText { - padding-top: 20px; -} diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index 120da4c4f1..b8bb1b04c2 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -146,3 +146,12 @@ limitations under the License. .mx_AuthBody_spinner { margin: 1em 0; } + +@media only screen and (max-width: 480px) { + .mx_AuthBody { + border-radius: 4px; + width: auto; + max-width: 500px; + padding: 10px; + } +} diff --git a/res/css/views/auth/_AuthHeader.scss b/res/css/views/auth/_AuthHeader.scss index b3d07b1925..b1372affee 100644 --- a/res/css/views/auth/_AuthHeader.scss +++ b/res/css/views/auth/_AuthHeader.scss @@ -21,3 +21,9 @@ limitations under the License. padding: 25px 40px; box-sizing: border-box; } + +@media only screen and (max-width: 480px) { + .mx_AuthHeader { + display: none; + } +} diff --git a/res/css/views/auth/_AuthHeaderLogo.scss b/res/css/views/auth/_AuthHeaderLogo.scss index 091fb0197b..917dcabf67 100644 --- a/res/css/views/auth/_AuthHeaderLogo.scss +++ b/res/css/views/auth/_AuthHeaderLogo.scss @@ -23,3 +23,9 @@ limitations under the License. .mx_AuthHeaderLogo img { width: 100%; } + +@media only screen and (max-width: 480px) { + .mx_AuthHeaderLogo { + display: none; + } +} diff --git a/res/css/views/auth/_AuthPage.scss b/res/css/views/auth/_AuthPage.scss index 8ef48b6265..e3409792f0 100644 --- a/res/css/views/auth/_AuthPage.scss +++ b/res/css/views/auth/_AuthPage.scss @@ -29,3 +29,9 @@ limitations under the License. box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.33); background-color: $authpage-modal-bg-color; } + +@media only screen and (max-width: 480px) { + .mx_AuthPage_modal { + margin-top: 0; + } +} diff --git a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss index 9f1d0f4998..63e5a3de09 100644 --- a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss +++ b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss @@ -73,42 +73,33 @@ limitations under the License. margin-left: 20px; } +.mx_CreateSecretStorageDialog_recoveryKeyHeader { + margin-bottom: 1em; +} + .mx_CreateSecretStorageDialog_recoveryKeyContainer { - width: 380px; - margin-left: auto; - margin-right: auto; + display: flex; } .mx_CreateSecretStorageDialog_recoveryKey { - font-weight: bold; - text-align: center; + width: 262px; padding: 20px; color: $info-plinth-fg-color; background-color: $info-plinth-bg-color; - border-radius: 6px; - word-spacing: 1em; - margin-bottom: 20px; + margin-right: 12px; } .mx_CreateSecretStorageDialog_recoveryKeyButtons { + flex: 1; display: flex; - justify-content: space-between; align-items: center; } .mx_CreateSecretStorageDialog_recoveryKeyButtons .mx_AccessibleButton { - width: 160px; - padding-left: 0px; - padding-right: 0px; + margin-right: 10px; +} + +.mx_CreateSecretStorageDialog_recoveryKeyButtons button { + flex: 1; white-space: nowrap; } - -.mx_CreateSecretStorageDialog_continueSpinner { - margin-top: 33px; - text-align: right; -} - -.mx_CreateSecretStorageDialog_continueSpinner img { - width: 20px; - height: 20px; -} diff --git a/res/css/views/elements/_StyledCheckbox.scss b/res/css/views/elements/_StyledCheckbox.scss index df0b8c6d94..aab448605c 100644 --- a/res/css/views/elements/_StyledCheckbox.scss +++ b/res/css/views/elements/_StyledCheckbox.scss @@ -25,6 +25,8 @@ limitations under the License. input[type=checkbox] { appearance: none; + margin: 0; + padding: 0; & + label { display: flex; @@ -68,5 +70,15 @@ limitations under the License. & + label > *:not(.mx_Checkbox_background) { margin-left: 10px; } + + &:disabled + label { + opacity: 0.5; + cursor: not-allowed; + } + + &:checked:disabled + label > .mx_Checkbox_background { + background-color: $muted-fg-color; + border-color: rgba($muted-fg-color, 0.5); + } } } diff --git a/res/css/views/elements/_StyledRadioButton.scss b/res/css/views/elements/_StyledRadioButton.scss index a3ae823079..c2edb359dc 100644 --- a/res/css/views/elements/_StyledRadioButton.scss +++ b/res/css/views/elements/_StyledRadioButton.scss @@ -20,7 +20,6 @@ limitations under the License. */ .mx_RadioButton { - $radio-circle-color: $muted-fg-color; $active-radio-circle-color: $accent-color; position: relative; @@ -76,22 +75,32 @@ limitations under the License. border-radius: $font-8px; } } - } - > input[type=radio]:checked { - + div { - border-color: $active-radio-circle-color; + &:checked { + & + div { + border-color: $active-radio-circle-color; - > div { - background: $active-radio-circle-color; + & > div { + background: $active-radio-circle-color; + } } } - } - > input[type=radio]:disabled { - + div { - > div { - display: none; + &:disabled { + & + div, + & + div + span { + opacity: 0.5; + cursor: not-allowed; + } + + & + div { + border-color: $radio-circle-color; + } + } + + &:checked:disabled { + & + div > div { + background-color: $radio-circle-color; } } } diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 40a80f17bb..2b204955d8 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -572,3 +572,14 @@ limitations under the License. margin-left: 1em; } } + +@media only screen and (max-width: 480px) { + .mx_EventTile_line, .mx_EventTile_reply { + padding-left: 0; + margin-right: 0; + } + .mx_EventTile_content { + margin-top: 10px; + margin-right: 0; + } +} diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index 80f6c40f39..a047a6f9b4 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -267,3 +267,12 @@ limitations under the License. .mx_RoomHeader_pinsIndicatorUnread { background-color: $pinned-unread-color; } + +@media only screen and (max-width: 480px) { + .mx_RoomHeader_wrapper { + padding: 0; + } + .mx_RoomHeader { + overflow: hidden; + } +} diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index 3f5f654494..66615fb6a8 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -27,12 +27,61 @@ limitations under the License. width: 100%; .mx_RoomSublist2_headerContainer { - // Create a flexbox to make ordering easy + // Create a flexbox to make alignment easy display: flex; align-items: center; + + // *************************** + // Sticky Headers Start + + // Ideally we'd be able to use `position: sticky; top: 0; bottom: 0;` on the + // headerContainer, however due to our layout concerns we actually have to + // calculate it manually so we can sticky things in the right places. We also + // target the headerText instead of the container to reduce jumps when scrolling, + // and to help hide the badges/other buttons that could appear on hover. This + // all works by ensuring the header text has a fixed height when sticky so the + // fixed height of the container can maintain the scroll position. + + // The combined height must be set in the LeftPanel2 component for sticky headers + // to work correctly. padding-bottom: 8px; height: 24px; + .mx_RoomSublist2_stickable { + flex: 1; + max-width: 100%; + z-index: 2; // Prioritize headers in the visible list over sticky ones + + // Set the same background color as the room list for sticky headers + background-color: $roomlist2-bg-color; + + // Create a flexbox to make ordering easy + display: flex; + align-items: center; + + // We use a generic sticky class for 2 reasons: to reduce style duplication and + // to identify when a header is sticky. If we didn't have a consistent sticky class, + // we'd have to do the "is sticky" checks again on click, as clicking the header + // when sticky scrolls instead of collapses the list. + &.mx_RoomSublist2_headerContainer_sticky { + position: fixed; + z-index: 1; // over top of other elements, but still under the ones in the visible list + height: 32px; // to match the header container + // width set by JS + } + + &.mx_RoomSublist2_headerContainer_stickyBottom { + bottom: 0; + } + + // We don't have a top style because the top is dependent on the room list header's + // height, and is therefore calculated in JS. + // The class, mx_RoomSublist2_headerContainer_stickyTop, is applied though. + } + + // Sticky Headers End + // *************************** + .mx_RoomSublist2_badgeContainer { opacity: 0.8; width: 16px; @@ -76,18 +125,45 @@ limitations under the License. } .mx_RoomSublist2_headerText { + flex: 1; + max-width: calc(100% - 16px); // 16px is the badge width text-transform: uppercase; opacity: 0.5; line-height: $font-16px; font-size: $font-12px; - flex: 1; - max-width: calc(100% - 16px); // 16px is the badge width - // Ellipsize any text overflow text-overflow: ellipsis; overflow: hidden; white-space: nowrap; + + .mx_RoomSublist2_collapseBtn { + display: inline-block; + position: relative; + + // Default hidden + visibility: hidden; + width: 0; + height: 0; + + &::before { + content: ''; + width: 12px; + height: 12px; + position: absolute; + top: 1px; + left: 1px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $primary-fg-color; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + + &.mx_RoomSublist2_collapseBtn_collapsed::before { + mask-image: url('$(res)/img/feather-customised/chevron-right.svg'); + } + } } } @@ -100,7 +176,7 @@ limitations under the License. flex-direction: column; overflow: hidden; - .mx_RoomSublist2_showMoreButton { + .mx_RoomSublist2_showNButton { cursor: pointer; font-size: $font-13px; line-height: $font-18px; @@ -129,18 +205,25 @@ limitations under the License. display: flex; align-items: center; - .mx_RoomSublist2_showMoreButtonChevron { + .mx_RoomSublist2_showNButtonChevron { position: relative; width: 16px; height: 16px; margin-left: 12px; margin-right: 18px; - mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); mask-position: center; mask-size: contain; mask-repeat: no-repeat; background: $roomtile2-preview-color; } + + .mx_RoomSublist2_showMoreButtonChevron { + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + + .mx_RoomSublist2_showLessButtonChevron { + mask-image: url('$(res)/img/feather-customised/chevron-up.svg'); + } } // Class name comes from the ResizableBox component @@ -201,6 +284,17 @@ limitations under the License. background-color: $roomlist2-button-bg-color; } } + + .mx_RoomSublist2_headerContainer { + .mx_RoomSublist2_headerText { + .mx_RoomSublist2_collapseBtn { + visibility: visible; + width: 12px; + height: 12px; + margin-right: 4px; + } + } + } } &.mx_RoomSublist2_minimized { @@ -239,10 +333,10 @@ limitations under the License. .mx_RoomSublist2_resizeBox { align-items: center; - .mx_RoomSublist2_showMoreButton { + .mx_RoomSublist2_showNButton { flex-direction: column; - .mx_RoomSublist2_showMoreButtonChevron { + .mx_RoomSublist2_showNButtonChevron { margin-right: 12px; // to center } } @@ -320,8 +414,4 @@ limitations under the License. .mx_RadioButton, .mx_Checkbox { margin-top: 8px; } - - .mx_Checkbox { - margin-left: -8px; // to counteract the indent from the component - } } diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss index f74d0ff5a4..001499fea5 100644 --- a/res/css/views/rooms/_RoomTile2.scss +++ b/res/css/views/rooms/_RoomTile2.scss @@ -32,6 +32,13 @@ limitations under the License. .mx_RoomTile2_avatarContainer { margin-right: 8px; + position: relative; + + .mx_RoomTileIcon { + position: absolute; + bottom: 0; + right: 0; + } } .mx_RoomTile2_nameContainer { diff --git a/res/css/views/rooms/_RoomTileIcon.scss b/res/css/views/rooms/_RoomTileIcon.scss new file mode 100644 index 0000000000..adc8ea2994 --- /dev/null +++ b/res/css/views/rooms/_RoomTileIcon.scss @@ -0,0 +1,69 @@ +/* +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. +*/ + +.mx_RoomTileIcon { + width: 12px; + height: 12px; + border-radius: 12px; + background-color: $roomlist2-bg-color; // to match the room list itself +} + +.mx_RoomTileIcon_globe::before { + content: ''; + width: 8px; + height: 8px; + top: 2px; + left: 2px; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $primary-fg-color; + mask-image: url('$(res)/img/globe.svg'); +} + +.mx_RoomTileIcon_offline::before { + content: ''; + width: 8px; + height: 8px; + top: 2px; + left: 2px; + position: absolute; + border-radius: 8px; + background-color: $presence-offline; +} + +.mx_RoomTileIcon_online::before { + content: ''; + width: 8px; + height: 8px; + top: 2px; + left: 2px; + position: absolute; + border-radius: 8px; + background-color: $presence-online; +} + +.mx_RoomTileIcon_away::before { + content: ''; + width: 8px; + height: 8px; + top: 2px; + left: 2px; + position: absolute; + border-radius: 8px; + background-color: $presence-away; +} diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss index 7308bb7177..0756e98782 100644 --- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss @@ -44,6 +44,83 @@ limitations under the License. padding-right: 5px; } +.mx_AppearanceUserSettingsTab { + > .mx_SettingsTab_SubHeading { + margin-bottom: 32px; + } +} + +.mx_AppearanceUserSettingsTab_themeSection { + $radio-bg-color: $input-darker-bg-color; + color: $primary-fg-color; + + > .mx_ThemeSelectors { + display: flex; + flex-direction: row; + flex-wrap: wrap; + + margin-top: 4px; + margin-bottom: 30px; + + > .mx_RadioButton { + padding: $font-16px; + box-sizing: border-box; + border-radius: 10px; + width: 180px; + + background: $radio-bg-color; + opacity: 0.4; + + flex-shrink: 1; + flex-grow: 0; + + margin-right: 15px; + margin-top: 10px; + + font-weight: 600; + color: $muted-fg-color; + + > span { + justify-content: center; + } + } + + > .mx_RadioButton_enabled { + opacity: 1; + + // These colors need to be hardcoded because they don't change with the theme + &.mx_ThemeSelector_light { + background-color: #f3f8fd; + color: #2e2f32; + } + + &.mx_ThemeSelector_dark { + background-color: #181b21; + color: #f3f8fd; + + > input > div { + border-color: $input-darker-bg-color; + > div { + border-color: $input-darker-bg-color; + } + } + } + + &.mx_ThemeSelector_black { + background-color: #000000; + color: #f3f8fd; + + > input > div { + border-color: $input-darker-bg-color; + > div { + border-color: $input-darker-bg-color; + } + } + } + } + } +} + .mx_SettingsTab_customFontSizeField { margin-left: calc($font-16px + 10px); } diff --git a/res/img/feather-customised/chevron-right.svg b/res/img/feather-customised/chevron-right.svg new file mode 100644 index 0000000000..258de414a1 --- /dev/null +++ b/res/img/feather-customised/chevron-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/feather-customised/chevron-up.svg b/res/img/feather-customised/chevron-up.svg new file mode 100644 index 0000000000..4eb5ecc33e --- /dev/null +++ b/res/img/feather-customised/chevron-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/globe.svg b/res/img/globe.svg new file mode 100644 index 0000000000..cc22bc6e66 --- /dev/null +++ b/res/img/globe.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 18a25b2663..355cc1301c 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -186,6 +186,10 @@ $roomtile2-preview-color: #9e9e9e; $roomtile2-default-badge-bg-color: #61708b; $roomtile2-selected-bg-color: #FFF; +$presence-online: $accent-color; +$presence-away: orange; // TODO: Get color +$presence-offline: #E3E8F0; + // ******************** $roomtile-name-color: #61708b; diff --git a/src/@types/common.ts b/src/@types/common.ts index 26e5317aa3..9109993541 100644 --- a/src/@types/common.ts +++ b/src/@types/common.ts @@ -15,5 +15,5 @@ limitations under the License. */ // Based on https://stackoverflow.com/a/53229857/3532235 -export type Without = {[P in Exclude] ? : never} +export type Without = {[P in Exclude] ? : never}; export type XOR = (T | U) extends object ? (Without & U) | (Without & T) : T | U; diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 520c3fbe46..d54dc7dd23 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -150,7 +150,7 @@ export default abstract class BasePlatform { abstract displayNotification(title: string, msg: string, avatarUrl: string, room: Object); loudNotification(ev: Event, room: Object) { - }; + } /** * Returns a promise that resolves to a string representing the current version of the application. diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js index d40f820ac0..a80c91a59a 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -20,7 +20,6 @@ import {MatrixClientPeg} from './MatrixClientPeg'; import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; import { _t } from './languageHandler'; -import SettingsStore from './settings/SettingsStore'; import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; // This stores the secret storage private keys in memory for the JS SDK. This is @@ -30,14 +29,9 @@ import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; // operation ends. let secretStorageKeys = {}; let secretStorageBeingAccessed = false; -// Stores the 'passphraseOnly' option for the active storage access operation -let passphraseOnlyOption = null; function isCachingAllowed() { - return ( - secretStorageBeingAccessed || - SettingsStore.getValue("keepSecretStoragePassphraseForSession") - ); + return secretStorageBeingAccessed; } export class AccessCancelledError extends Error { @@ -101,7 +95,6 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { const key = await inputToKey(input); return await MatrixClientPeg.get().checkSecretStorageKey(key, info); }, - passphraseOnly: passphraseOnlyOption, }, /* className= */ null, /* isPriorityModal= */ false, @@ -216,27 +209,19 @@ export async function promptForBackupPassphrase() { * * @param {Function} [func] An operation to perform once secret storage has been * bootstrapped. Optional. - * @param {object} [opts] Named options - * @param {bool} [opts.forceReset] Reset secret storage even if it's already set up - * @param {object} [opts.withKeys] Map of key ID to key for SSSS keys that the client - * already has available. If a key is not supplied here, the user will be prompted. - * @param {bool} [opts.passphraseOnly] If true, do not prompt for recovery key or to reset keys + * @param {bool} [forceReset] Reset secret storage even if it's already set up */ -export async function accessSecretStorage( - func = async () => { }, opts = {}, -) { +export async function accessSecretStorage(func = async () => { }, forceReset = false) { const cli = MatrixClientPeg.get(); secretStorageBeingAccessed = true; - passphraseOnlyOption = opts.passphraseOnly; - secretStorageKeys = Object.assign({}, opts.withKeys || {}); try { - if (!await cli.hasSecretStorageKey() || opts.forceReset) { + if (!await cli.hasSecretStorageKey() || forceReset) { // This dialog calls bootstrap itself after guiding the user through // passphrase creation. const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '', import("./async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"), { - force: opts.forceReset, + force: forceReset, }, null, /* priority = */ false, /* static = */ true, ); @@ -274,6 +259,5 @@ export async function accessSecretStorage( if (!isCachingAllowed()) { secretStorageKeys = {}; } - passphraseOnlyOption = null; } } diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index e73b56416b..cfec2890d2 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -119,26 +119,26 @@ export default class DeviceListener { // No need to do a recheck here: we just need to get a snapshot of our devices // before we download any new ones. - } + }; _onDevicesUpdated = (users: string[]) => { if (!users.includes(MatrixClientPeg.get().getUserId())) return; this._recheck(); - } + }; _onDeviceVerificationChanged = (userId: string) => { if (userId !== MatrixClientPeg.get().getUserId()) return; this._recheck(); - } + }; _onUserTrustStatusChanged = (userId: string) => { if (userId !== MatrixClientPeg.get().getUserId()) return; this._recheck(); - } + }; _onCrossSingingKeysChanged = () => { this._recheck(); - } + }; _onAccountData = (ev) => { // User may have: @@ -152,11 +152,11 @@ export default class DeviceListener { ) { this._recheck(); } - } + }; _onSync = (state, prevState) => { if (state === 'PREPARED' && prevState === null) this._recheck(); - } + }; // The server doesn't tell us when key backup is set up, so we poll // & cache the result diff --git a/src/Login.js b/src/Login.js index 1590e5ac28..04805b4af9 100644 --- a/src/Login.js +++ b/src/Login.js @@ -95,6 +95,8 @@ export default class Login { identifier = { type: 'm.id.phone', country: phoneCountry, + phone: phoneNumber, + // XXX: Synapse historically wanted `number` and not `phone` number: phoneNumber, }; } else if (isEmail) { diff --git a/src/Markdown.js b/src/Markdown.js index fb1f8bf0ea..d312b7c5bd 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -175,14 +175,6 @@ export default class Markdown { const renderer = new commonmark.HtmlRenderer({safe: false}); const real_paragraph = renderer.paragraph; - // The default `out` function only sends the input through an XML - // escaping function, which causes messages to be entity encoded, - // which we don't want in this case. - renderer.out = function(s) { - // The `lit` function adds a string literal to the output buffer. - this.lit(s); - }; - renderer.paragraph = function(node, entering) { // as with toHTML, only append lines to paragraphs if there are // multiple paragraphs diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index bc550c1935..5f334a639c 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -35,13 +35,13 @@ import { crossSigningCallbacks } from './CrossSigningManager'; import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode"; export interface IMatrixClientCreds { - homeserverUrl: string, - identityServerUrl: string, - userId: string, - deviceId: string, - accessToken: string, - guest: boolean, - pickleKey?: string, + homeserverUrl: string; + identityServerUrl: string; + userId: string; + deviceId: string; + accessToken: string; + guest: boolean; + pickleKey?: string; } // TODO: Move this to the js-sdk diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 15798ae3b1..7ebdc4ee3b 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -118,7 +118,7 @@ export class Command { run(roomId: string, args: string, cmd: string) { // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` - if (!this.runFn) return; + if (!this.runFn) return reject(_t("Command error")); return this.runFn.bind(this)(roomId, args, cmd); } diff --git a/src/actions/TagOrderActions.ts b/src/actions/TagOrderActions.ts index bf1820d5d1..75097952c0 100644 --- a/src/actions/TagOrderActions.ts +++ b/src/actions/TagOrderActions.ts @@ -60,7 +60,7 @@ export default class TagOrderActions { // For an optimistic update return {tags, removedTags}; }); - }; + } /** * Creates an action thunk that will do an asynchronous request to diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js index 192427d384..d7b79c2cfa 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -20,23 +20,25 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import FileSaver from 'file-saver'; -import {_t} from '../../../../languageHandler'; +import {_t, _td} from '../../../../languageHandler'; import Modal from '../../../../Modal'; import { promptForBackupPassphrase } from '../../../../CrossSigningManager'; import {copyNode} from "../../../../utils/strings"; import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents"; -import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; -import DialogButtons from "../../../../components/views/elements/DialogButtons"; -import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; - +import PassphraseField from "../../../../components/views/auth/PassphraseField"; const PHASE_LOADING = 0; const PHASE_LOADERROR = 1; const PHASE_MIGRATE = 2; -const PHASE_INTRO = 3; -const PHASE_SHOWKEY = 4; -const PHASE_STORING = 5; -const PHASE_CONFIRM_SKIP = 6; +const PHASE_PASSPHRASE = 3; +const PHASE_PASSPHRASE_CONFIRM = 4; +const PHASE_SHOWKEY = 5; +const PHASE_KEEPITSAFE = 6; +const PHASE_STORING = 7; +const PHASE_DONE = 8; +const PHASE_CONFIRM_SKIP = 9; + +const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. /* * Walks the user through the process of creating a passphrase to guard Secure @@ -63,32 +65,34 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.state = { phase: PHASE_LOADING, - downloaded: false, + passPhrase: '', + passPhraseValid: false, + passPhraseConfirm: '', copied: false, + downloaded: false, backupInfo: null, - backupInfoFetched: false, - backupInfoFetchError: null, backupSigStatus: null, // does the server offer a UI auth flow with just m.login.password - // for /keys/device_signing/upload? (If we have an account password, we - // assume that it can) + // for /keys/device_signing/upload? canUploadKeysWithPasswordOnly: null, - canUploadKeyCheckInProgress: false, accountPassword: props.accountPassword || "", accountPasswordCorrect: null, - // No toggle for this: if we really don't want one, remove it & just hard code true + // status of the key backup toggle switch useKeyBackup: true, }; - if (props.accountPassword) { - // If we have an account password, we assume we can upload keys with - // just a password (otherwise leave it as null so we poll to check) - this.state.canUploadKeysWithPasswordOnly = true; - } - this._passphraseField = createRef(); - this.loadData(); + this._fetchBackupInfo(); + if (this.state.accountPassword) { + // If we have an account password in memory, let's simplify and + // assume it means password auth is also supported for device + // signing key upload as well. This avoids hitting the server to + // test auth flows, which may be slow under high load. + this.state.canUploadKeysWithPasswordOnly = true; + } else { + this._queryKeyUploadAuth(); + } MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); } @@ -105,11 +109,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent { MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo) ); + const { force } = this.props; + const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_PASSPHRASE; + this.setState({ - backupInfoFetched: true, + phase, backupInfo, backupSigStatus, - backupInfoFetchError: null, }); return { @@ -117,25 +123,20 @@ export default class CreateSecretStorageDialog extends React.PureComponent { backupSigStatus, }; } catch (e) { - this.setState({backupInfoFetchError: e}); + this.setState({phase: PHASE_LOADERROR}); } } async _queryKeyUploadAuth() { try { - this.setState({canUploadKeyCheckInProgress: true}); await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {}); // We should never get here: the server should always require // UI auth to upload device signing keys. If we do, we upload // no keys which would be a no-op. console.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); - this.setState({canUploadKeyCheckInProgress: false}); } catch (error) { if (!error.data || !error.data.flows) { console.log("uploadDeviceSigningKeys advertised no flows!"); - this.setState({ - canUploadKeyCheckInProgress: false, - }); return; } const canUploadKeysWithPasswordOnly = error.data.flows.some(f => { @@ -143,18 +144,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }); this.setState({ canUploadKeysWithPasswordOnly, - canUploadKeyCheckInProgress: false, }); } } - async _createRecoveryKey() { - this._recoveryKey = await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); - this.setState({ - phase: PHASE_SHOWKEY, - }); - } - _onKeyBackupStatusChange = () => { if (this.state.phase === PHASE_MIGRATE) this._fetchBackupInfo(); } @@ -163,6 +156,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this._recoveryKeyNode = n; } + _onUseKeyBackupChange = (enabled) => { + this.setState({ + useKeyBackup: enabled, + }); + } + _onMigrateFormSubmit = (e) => { e.preventDefault(); if (this.state.backupSigStatus.usable) { @@ -172,15 +171,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } } - _onIntroContinueClick = () => { - this._createRecoveryKey(); - } - _onCopyClick = () => { const successful = copyNode(this._recoveryKeyNode); if (successful) { this.setState({ copied: true, + phase: PHASE_KEEPITSAFE, }); } } @@ -190,8 +186,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { type: 'text/plain;charset=us-ascii', }); FileSaver.saveAs(blob, 'recovery-key.txt'); + this.setState({ downloaded: true, + phase: PHASE_KEEPITSAFE, }); } @@ -247,9 +245,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _bootstrapSecretStorage = async () => { this.setState({ - // we use LOADING here rather than STORING as STORING still shows the 'show key' - // screen which is not relevant: LOADING is just a generic spinner. - phase: PHASE_LOADING, + phase: PHASE_STORING, error: null, }); @@ -290,7 +286,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }, }); } - this.props.onFinished(true); + this.setState({ + phase: PHASE_DONE, + }); } catch (e) { if (this.state.canUploadKeysWithPasswordOnly && e.httpStatus === 401 && e.data.flows) { this.setState({ @@ -309,6 +307,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.props.onFinished(false); } + _onDone = () => { + this.props.onFinished(true); + } + _restoreBackup = async () => { // It's possible we'll need the backup key later on for bootstrapping, // so let's stash it here, rather than prompting for it twice. @@ -335,41 +337,88 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } } - _onShowKeyContinueClick = () => { - this._bootstrapSecretStorage(); - } - _onLoadRetryClick = () => { - this.loadData(); - } - - async loadData() { this.setState({phase: PHASE_LOADING}); - const proms = []; - - if (!this.state.backupInfoFetched) proms.push(this._fetchBackupInfo()); - if (this.state.canUploadKeysWithPasswordOnly === null) proms.push(this._queryKeyUploadAuth()); - - await Promise.all(proms); - if (this.state.canUploadKeysWithPasswordOnly === null || this.state.backupInfoFetchError) { - this.setState({phase: PHASE_LOADERROR}); - } else if (this.state.backupInfo && !this.props.force) { - this.setState({phase: PHASE_MIGRATE}); - } else { - this.setState({phase: PHASE_INTRO}); - } + this._fetchBackupInfo(); } _onSkipSetupClick = () => { this.setState({phase: PHASE_CONFIRM_SKIP}); } - _onGoBackClick = () => { - if (this.state.backupInfo && !this.props.force) { - this.setState({phase: PHASE_MIGRATE}); - } else { - this.setState({phase: PHASE_INTRO}); + _onSetUpClick = () => { + this.setState({phase: PHASE_PASSPHRASE}); + } + + _onSkipPassPhraseClick = async () => { + this._recoveryKey = + await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); + this.setState({ + copied: false, + downloaded: false, + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseNextClick = async (e) => { + e.preventDefault(); + if (!this._passphraseField.current) return; // unmounting + + await this._passphraseField.current.validate({ allowEmpty: false }); + if (!this._passphraseField.current.state.valid) { + this._passphraseField.current.focus(); + this._passphraseField.current.validate({ allowEmpty: false, focused: true }); + return; } + + this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); + }; + + _onPassPhraseConfirmNextClick = async (e) => { + e.preventDefault(); + + if (this.state.passPhrase !== this.state.passPhraseConfirm) return; + + this._recoveryKey = + await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase); + this.setState({ + copied: false, + downloaded: false, + phase: PHASE_SHOWKEY, + }); + } + + _onSetAgainClick = () => { + this.setState({ + passPhrase: '', + passPhraseValid: false, + passPhraseConfirm: '', + phase: PHASE_PASSPHRASE, + }); + } + + _onKeepItSafeBackClick = () => { + this.setState({ + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseValidate = (result) => { + this.setState({ + passPhraseValid: result.valid, + }); + }; + + _onPassPhraseChange = (e) => { + this.setState({ + passPhrase: e.target.value, + }); + } + + _onPassPhraseConfirmChange = (e) => { + this.setState({ + passPhraseConfirm: e.target.value, + }); } _onAccountPasswordChange = (e) => { @@ -384,14 +433,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent { // Once we're confident enough in this (and it's supported enough) we can do // it automatically. // https://github.com/vector-im/riot-web/issues/11696 + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const Field = sdk.getComponent('views.elements.Field'); let authPrompt; let nextCaption = _t("Next"); - if (!this.state.backupSigStatus.usable) { - authPrompt = null; - nextCaption = _t("Upload"); - } else if (this.state.canUploadKeysWithPasswordOnly && !this.props.accountPassword) { + if (this.state.canUploadKeysWithPasswordOnly) { authPrompt =
{_t("Enter your account password to confirm the upgrade:")}
; + } else if (!this.state.backupSigStatus.usable) { + authPrompt =
+
{_t("Restore your key backup to upgrade your encryption")}
+
; + nextCaption = _t("Restore"); } else { authPrompt =

{_t("You'll need to authenticate with the server to confirm the upgrade.")} @@ -411,9 +463,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { return

{_t( - "Upgrade your Recovery Key to store encryption keys & secrets " + - "with your account data. If you lose access to this login you'll " + - "need it to unlock your data.", + "Upgrade this session to allow it to verify other sessions, " + + "granting them access to encrypted messages and marking them " + + "as trusted for other users.", )}

{authPrompt}
; } - _renderPhaseShowKey() { - let continueButton; - if (this.state.phase === PHASE_SHOWKEY) { - continueButton = +

{_t( + "Set a recovery passphrase to secure encrypted information and recover it if you log out. " + + "This should be different to your account password:", + )}

+ +
+ +
+ + + + ; - } else { - continueButton =
- -
; + disabled={!this.state.passPhraseValid} + > + +
+ +
+ {_t("Advanced")} + + {_t("Set up with a recovery key")} + +
+ ; + } + + _renderPhasePassPhraseConfirm() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const Field = sdk.getComponent('views.elements.Field'); + + let matchText; + let changeText; + if (this.state.passPhraseConfirm === this.state.passPhrase) { + matchText = _t("That matches!"); + changeText = _t("Use a different passphrase?"); + } else if (!this.state.passPhrase.startsWith(this.state.passPhraseConfirm)) { + // only tell them they're wrong if they've actually gone wrong. + // Security concious readers will note that if you left riot-web unattended + // on this screen, this would make it easy for a malicious person to guess + // your passphrase one letter at a time, but they could get this faster by + // just opening the browser's developer tools and reading it. + // Note that not having typed anything at all will not hit this clause and + // fall through so empty box === no hint. + matchText = _t("That doesn't match."); + changeText = _t("Go back to set it again."); } + let passPhraseMatch = null; + if (matchText) { + passPhraseMatch =
+
{matchText}
+
+ + {changeText} + +
+
; + } + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+

{_t( + "Enter your recovery passphrase a second time to confirm it.", + )}

+
+ +
+ {passPhraseMatch} +
+
+ + + +
; + } + + _renderPhaseShowKey() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return

{_t( - "Store your Recovery Key somewhere safe, it can be used to unlock your encrypted messages & data.", + "Your recovery key is a safety net - you can use it to restore " + + "access to your encrypted messages if you forget your recovery passphrase.", + )}

+

{_t( + "Keep a copy of it somewhere secure, like a password manager or even a safe.", )}

+
+ {_t("Your recovery key")} +
{this._recoveryKey.encodedPrivateKey}
- - {_t("Download")} - - {_t("or")} - {this.state.copied ? _t("Copied!") : _t("Copy")} + {_t("Copy")} + + + {_t("Download")}
- {continueButton} +
; + } + + _renderPhaseKeepItSafe() { + let introText; + if (this.state.copied) { + introText = _t( + "Your recovery key has been copied to your clipboard, paste it to:", + {}, {b: s => {s}}, + ); + } else if (this.state.downloaded) { + introText = _t( + "Your recovery key is in your Downloads folder.", + {}, {b: s => {s}}, + ); + } + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+ {introText} +
    +
  • {_t("Print it and store it somewhere safe", {}, {b: s => {s}})}
  • +
  • {_t("Save it on a USB key or backup drive", {}, {b: s => {s}})}
  • +
  • {_t("Copy it to your personal cloud storage", {}, {b: s => {s}})}
  • +
+ + +
; } @@ -483,6 +671,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } _renderPhaseLoadError() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

{_t("Unable to query secret storage status")}

@@ -495,44 +684,29 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
; } - _renderPhaseIntro() { - let cancelButton; - if (this.props.force) { - // if this is a forced key reset then aborting will just leave the old keys - // in place, and is thereforece just 'cancel' - cancelButton = ; - } else { - // if it's setting up from scratch then aborting leaves the user without - // crypto set up, so they skipping the setup. - cancelButton = ; - } - + _renderPhaseDone() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

{_t( - "Create a Recovery Key to store encryption keys & secrets with your account data. " + - "If you lose access to this login you’ll need it to unlock your data.", + "You can now verify your other devices, " + + "and other users to keep your chats safe.", )}

-
- - {cancelButton} - -
+
; } _renderPhaseSkipConfirm() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
{_t( "Without completing security on this session, it won’t have " + "access to encrypted messages.", )} @@ -542,15 +716,21 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _titleForPhase(phase) { switch (phase) { - case PHASE_INTRO: - return _t('Create a Recovery Key'); case PHASE_MIGRATE: - return _t('Upgrade your Recovery Key'); + return _t('Upgrade your encryption'); + case PHASE_PASSPHRASE: + return _t('Set up encryption'); + case PHASE_PASSPHRASE_CONFIRM: + return _t('Confirm recovery passphrase'); case PHASE_CONFIRM_SKIP: return _t('Are you sure?'); case PHASE_SHOWKEY: + case PHASE_KEEPITSAFE: + return _t('Make a copy of your recovery key'); case PHASE_STORING: - return _t('Store your Recovery Key'); + return _t('Setting up keys'); + case PHASE_DONE: + return _t("You're done!"); default: return ''; } @@ -561,6 +741,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { let content; if (this.state.error) { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); content =

{_t("Unable to set up secret storage")}

@@ -579,16 +760,27 @@ export default class CreateSecretStorageDialog extends React.PureComponent { case PHASE_LOADERROR: content = this._renderPhaseLoadError(); break; - case PHASE_INTRO: - content = this._renderPhaseIntro(); - break; case PHASE_MIGRATE: content = this._renderPhaseMigrate(); break; + case PHASE_PASSPHRASE: + content = this._renderPhasePassPhrase(); + break; + case PHASE_PASSPHRASE_CONFIRM: + content = this._renderPhasePassPhraseConfirm(); + break; case PHASE_SHOWKEY: - case PHASE_STORING: content = this._renderPhaseShowKey(); break; + case PHASE_KEEPITSAFE: + content = this._renderPhaseKeepItSafe(); + break; + case PHASE_STORING: + content = this._renderBusyPhase(); + break; + case PHASE_DONE: + content = this._renderPhaseDone(); + break; case PHASE_CONFIRM_SKIP: content = this._renderPhaseSkipConfirm(); break; @@ -605,7 +797,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { onFinished={this.props.onFinished} title={this._titleForPhase(this.state.phase)} headerImage={headerImage} - hasCancel={this.props.hasCancel} + hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)} fixedWidth={false} >
diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index 8384eb9d4f..2615736e09 100644 --- a/src/autocomplete/Autocompleter.ts +++ b/src/autocomplete/Autocompleter.ts @@ -35,15 +35,15 @@ export interface ISelectionRange { export interface ICompletion { type: "at-room" | "command" | "community" | "room" | "user"; - completion: string, + completion: string; completionId?: string; - component?: ReactElement, - range: ISelectionRange, - command?: string, + component?: ReactElement; + range: ISelectionRange; + command?: string; suffix?: string; // If provided, apply a LINK entity to the completion with the // data = { url: href }. - href?: string, + href?: string; } const PROVIDERS = [ diff --git a/src/autocomplete/Components.tsx b/src/autocomplete/Components.tsx index 0ee0088f02..6ac2f4db14 100644 --- a/src/autocomplete/Components.tsx +++ b/src/autocomplete/Components.tsx @@ -46,7 +46,7 @@ export const TextualCompletion = forwardRef((props }); interface IPillCompletionProps extends ITextualCompletionProps { - children?: React.ReactNode, + children?: React.ReactNode; } export const PillCompletion = forwardRef((props, ref) => { diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index a644aa4837..b5da44caef 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -86,6 +86,45 @@ export default class LeftPanel2 extends React.Component { } }; + // TODO: Apply this on resize, init, etc for reliability + private onScroll = (ev: React.MouseEvent) => { + const list = ev.target as HTMLDivElement; + const rlRect = list.getBoundingClientRect(); + const bottom = rlRect.bottom; + const top = rlRect.top; + const sublists = list.querySelectorAll(".mx_RoomSublist2"); + const headerHeight = 32; // Note: must match the CSS! + const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles + + const headerStickyWidth = rlRect.width - headerRightMargin; + + let gotBottom = false; + for (const sublist of sublists) { + const slRect = sublist.getBoundingClientRect(); + + const header = sublist.querySelector(".mx_RoomSublist2_stickable"); + + if (slRect.top + headerHeight > bottom && !gotBottom) { + header.classList.add("mx_RoomSublist2_headerContainer_sticky"); + header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom"); + header.style.width = `${headerStickyWidth}px`; + header.style.top = `unset`; + gotBottom = true; + } else if (slRect.top < top) { + header.classList.add("mx_RoomSublist2_headerContainer_sticky"); + header.classList.add("mx_RoomSublist2_headerContainer_stickyTop"); + header.style.width = `${headerStickyWidth}px`; + header.style.top = `${rlRect.top}px`; + } else { + header.classList.remove("mx_RoomSublist2_headerContainer_sticky"); + header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop"); + header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom"); + header.style.width = `unset`; + header.style.top = `unset`; + } + } + }; + private renderHeader(): React.ReactNode { // TODO: Update when profile info changes // TODO: Presence @@ -191,7 +230,7 @@ export default class LeftPanel2 extends React.Component { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 9cb6ed078c..fa6cd8a4d8 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -151,9 +151,9 @@ interface IProps { // TODO type things better // Represents the screen to display as a result of parsing the initial window.location initialScreenAfterLogin?: IScreen; // displayname, if any, to set on the device when logging in/registering. - defaultDeviceDisplayName?: string, + defaultDeviceDisplayName?: string; // A function that makes a registration URL - makeRegistrationUrl: (object) => string, + makeRegistrationUrl: (object) => string; } interface IState { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index ab3da035c4..4a0cc470d5 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1977,8 +1977,9 @@ export default createReactClass({ searchResultsPanel = (
); } else { searchResultsPanel = ( - diff --git a/src/components/structures/UserMenuButton.tsx b/src/components/structures/UserMenuButton.tsx index 41b2c3ab60..6607fffdd1 100644 --- a/src/components/structures/UserMenuButton.tsx +++ b/src/components/structures/UserMenuButton.tsx @@ -32,6 +32,8 @@ import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import {getCustomTheme} from "../../theme"; import {getHostingLink} from "../../utils/HostingLink"; import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; +import SdkConfig from "../../SdkConfig"; +import {getHomePageUrl} from "../../utils/pages"; interface IProps { } @@ -67,6 +69,10 @@ export default class UserMenuButton extends React.Component { } } + private get hasHomePage(): boolean { + return !!getHomePageUrl(SdkConfig.get()); + } + public componentDidMount() { this.dispatcherRef = defaultDispatcher.register(this.onAction); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); @@ -147,6 +153,13 @@ export default class UserMenuButton extends React.Component { this.setState({menuDisplayed: false}); // also close the menu }; + private onHomeClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + defaultDispatcher.dispatch({action: 'view_home_page'}); + }; + public render() { let contextMenu; if (this.state.menuDisplayed) { @@ -172,6 +185,18 @@ export default class UserMenuButton extends React.Component { ); } + let homeButton = null; + if (this.hasHomePage) { + homeButton = ( +
  • + + + {_t("Home")} + +
  • + ); + } + const elementRect = this.buttonRef.current.getBoundingClientRect(); contextMenu = ( { {hostingLink}
      + {homeButton}
    • this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}> @@ -265,6 +291,6 @@ export default class UserMenuButton extends React.Component { {contextMenu} - ) + ); } } diff --git a/src/components/structures/auth/CompleteSecurity.js b/src/components/structures/auth/CompleteSecurity.js index e38ecd3eac..c73691611d 100644 --- a/src/components/structures/auth/CompleteSecurity.js +++ b/src/components/structures/auth/CompleteSecurity.js @@ -21,7 +21,6 @@ import * as sdk from '../../../index'; import { SetupEncryptionStore, PHASE_INTRO, - PHASE_RECOVERY_KEY, PHASE_BUSY, PHASE_DONE, PHASE_CONFIRM_SKIP, @@ -62,9 +61,6 @@ export default class CompleteSecurity extends React.Component { if (phase === PHASE_INTRO) { icon = ; title = _t("Verify this login"); - } else if (phase === PHASE_RECOVERY_KEY) { - icon = ; - title = _t("Recovery Key"); } else if (phase === PHASE_DONE) { icon = ; title = _t("Session verified"); diff --git a/src/components/structures/auth/SetupEncryptionBody.js b/src/components/structures/auth/SetupEncryptionBody.js index 4cc5c5ef75..edb4a7689d 100644 --- a/src/components/structures/auth/SetupEncryptionBody.js +++ b/src/components/structures/auth/SetupEncryptionBody.js @@ -19,12 +19,9 @@ import PropTypes from 'prop-types'; import { _t, _td } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import * as sdk from '../../../index'; -import withValidation from '../../views/elements/Validation'; -import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; import { SetupEncryptionStore, PHASE_INTRO, - PHASE_RECOVERY_KEY, PHASE_BUSY, PHASE_DONE, PHASE_CONFIRM_SKIP, @@ -56,11 +53,6 @@ export default class SetupEncryptionBody extends React.Component { // Because of the latter, it lives in the state. verificationRequest: store.verificationRequest, backupInfo: store.backupInfo, - recoveryKey: '', - // whether the recovery key is a valid recovery key - recoveryKeyValid: null, - // whether the recovery key is the correct key or not - recoveryKeyCorrect: null, }; } @@ -83,19 +75,9 @@ export default class SetupEncryptionBody extends React.Component { store.stop(); } - _onResetClick = () => { + _onUsePassphraseClick = async () => { const store = SetupEncryptionStore.sharedInstance(); - store.startKeyReset(); - } - - _onUseRecoveryKeyClick = async () => { - const store = SetupEncryptionStore.sharedInstance(); - store.useRecoveryKey(); - } - - _onRecoveryKeyCancelClick() { - const store = SetupEncryptionStore.sharedInstance(); - store.cancelUseRecoveryKey(); + store.usePassPhrase(); } onSkipClick = () => { @@ -118,66 +100,6 @@ export default class SetupEncryptionBody extends React.Component { store.done(); } - _onUsePassphraseClick = () => { - const store = SetupEncryptionStore.sharedInstance(); - store.usePassPhrase(); - } - - _onRecoveryKeyChange = (e) => { - this.setState({recoveryKey: e.target.value}); - } - - _onRecoveryKeyValidate = async (fieldState) => { - const result = await this._validateRecoveryKey(fieldState); - this.setState({recoveryKeyValid: result.valid}); - return result; - } - - _validateRecoveryKey = withValidation({ - rules: [ - { - key: "required", - test: async (state) => { - try { - const decodedKey = decodeRecoveryKey(state.value); - const correct = await MatrixClientPeg.get().checkSecretStorageKey( - decodedKey, SetupEncryptionStore.sharedInstance().keyInfo, - ); - this.setState({ - recoveryKeyValid: true, - recoveryKeyCorrect: correct, - }); - return correct; - } catch (e) { - this.setState({ - recoveryKeyValid: false, - recoveryKeyCorrect: false, - }); - return false; - } - }, - invalid: function() { - if (this.state.recoveryKeyValid) { - return _t("This isn't the recovery key for your account"); - } else { - return _t("This isn't a valid recovery key"); - } - }, - valid: function() { - return _t("Looks good!"); - }, - }, - ], - }) - - _onRecoveryKeyFormSubmit = (e) => { - e.preventDefault(); - if (!this.state.recoveryKeyCorrect) return; - - const store = SetupEncryptionStore.sharedInstance(); - store.setupWithRecoveryKey(decodeRecoveryKey(this.state.recoveryKey)); - } - render() { const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); @@ -205,7 +127,7 @@ export default class SetupEncryptionBody extends React.Component { let useRecoveryKeyButton; let resetKeysCaption; if (recoveryKeyPrompt) { - useRecoveryKeyButton = + useRecoveryKeyButton = {recoveryKeyPrompt} ; resetKeysCaption = _td( @@ -245,58 +167,8 @@ export default class SetupEncryptionBody extends React.Component { {_t("Skip")}
    -
    {_t(resetKeysCaption, {}, { - button: sub => - {sub} - , - }, - )}
    ); - } else if (phase === PHASE_RECOVERY_KEY) { - const store = SetupEncryptionStore.sharedInstance(); - let keyPrompt; - if (keyHasPassphrase(store.keyInfo)) { - keyPrompt = _t( - "Enter your Recovery Key or enter a Recovery Passphrase to continue.", {}, - { - a: sub => {sub}, - }, - ); - } else { - keyPrompt = _t("Enter your Recovery Key to continue."); - } - - const Field = sdk.getComponent('elements.Field'); - return
    -

    {keyPrompt}

    -
    - -
    -
    - - {_t("Cancel")} - - - {_t("Continue")} - -
    -
    ; } else if (phase === PHASE_DONE) { let message; if (this.state.backupInfo) { diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index f09791ce26..2f5064447e 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -118,7 +118,7 @@ class PassphraseField extends PureComponent { value={this.props.value} onChange={this.props.onChange} onValidate={this.onValidate} - /> + />; } } diff --git a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js index 87ba6f7396..dd34dfbbf0 100644 --- a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js @@ -88,7 +88,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { _onResetRecoveryClick = () => { this.props.onFinished(false); - accessSecretStorage(() => {}, {forceReset: true}); + accessSecretStorage(() => {}, /* forceReset = */ true); } _onRecoveryKeyChange = (e) => { diff --git a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js index 43697f8ee7..e2ceadfbb9 100644 --- a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js +++ b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js @@ -32,9 +32,6 @@ export default class AccessSecretStorageDialog extends React.PureComponent { keyInfo: PropTypes.object.isRequired, // Function from one of { passphrase, recoveryKey } -> boolean checkPrivateKey: PropTypes.func.isRequired, - // If true, only prompt for a passphrase and do not offer to restore with - // a recovery key or reset keys. - passphraseOnly: PropTypes.bool, } constructor(props) { @@ -61,7 +58,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { _onResetRecoveryClick = () => { // Re-enter the access flow, but resetting storage this time around. this.props.onFinished(false); - accessSecretStorage(() => {}, {forceReset: true}); + accessSecretStorage(() => {}, /* forceReset = */ true); } _onRecoveryKeyChange = (e) => { @@ -167,7 +164,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { primaryDisabled={this.state.passPhrase.length === 0} /> - {this.props.passphraseOnly ? null : _t( + {_t( "If you've forgotten your recovery passphrase you can "+ "use your recovery key or " + "set up new recovery options." @@ -237,7 +234,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { primaryDisabled={!this.state.recoveryKeyValid} /> - {this.props.passphraseOnly ? null : _t( + {_t( "If you've forgotten your recovery key you can "+ "." , {}, { diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index 18dd43ad02..01a27d9522 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -19,7 +19,7 @@ import React from 'react'; import {Key} from '../../../Keyboard'; import classnames from 'classnames'; -export type ButtonEvent = React.MouseEvent | React.KeyboardEvent +export type ButtonEvent = React.MouseEvent | React.KeyboardEvent; /** * children: React's magic prop. Represents all children given to the element. @@ -40,7 +40,7 @@ interface IProps extends React.InputHTMLAttributes { disabled?: boolean; className?: string; onClick?(e?: ButtonEvent): void; -}; +} interface IAccessibleButtonProps extends React.InputHTMLAttributes { ref?: React.Ref; diff --git a/src/components/views/elements/Draggable.tsx b/src/components/views/elements/Draggable.tsx index 3096ac42f7..3397fd901c 100644 --- a/src/components/views/elements/Draggable.tsx +++ b/src/components/views/elements/Draggable.tsx @@ -17,20 +17,20 @@ limitations under the License. import React from 'react'; interface IProps { - className: string, - dragFunc: (currentLocation: ILocationState, event: MouseEvent) => ILocationState, - onMouseUp: (event: MouseEvent) => void, + className: string; + dragFunc: (currentLocation: ILocationState, event: MouseEvent) => ILocationState; + onMouseUp: (event: MouseEvent) => void; } interface IState { - onMouseMove: (event: MouseEvent) => void, - onMouseUp: (event: MouseEvent) => void, - location: ILocationState, + onMouseMove: (event: MouseEvent) => void; + onMouseUp: (event: MouseEvent) => void; + location: ILocationState; } export interface ILocationState { - currentX: number, - currentY: number, + currentX: number; + currentY: number; } export default class Draggable extends React.Component { @@ -58,13 +58,13 @@ export default class Draggable extends React.Component { document.addEventListener("mousemove", this.state.onMouseMove); document.addEventListener("mouseup", this.state.onMouseUp); - } + }; private onMouseUp = (event: MouseEvent): void => { document.removeEventListener("mousemove", this.state.onMouseMove); document.removeEventListener("mouseup", this.state.onMouseUp); this.props.onMouseUp(event); - } + }; private onMouseMove(event: MouseEvent): void { const newLocation = this.props.dragFunc(this.state.location, event); @@ -75,7 +75,7 @@ export default class Draggable extends React.Component { } render() { - return
    + return
    ; } } \ No newline at end of file diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 771d2182ea..fbee431d6e 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes} from 'react'; import classNames from 'classnames'; import * as sdk from '../../../index'; import { debounce } from 'lodash'; -import {IFieldState, IValidationResult} from "../elements/Validation"; +import {IFieldState, IValidationResult} from "./Validation"; // Invoke validation from user input (when typing, etc.) at most once every N ms. const VALIDATION_THROTTLE_MS = 200; @@ -29,60 +29,76 @@ function getId() { return `${BASE_ID}_${count++}`; } -interface IProps extends React.InputHTMLAttributes { +interface IProps { // The field's ID, which binds the input and label together. Immutable. - id?: string, - // The element to create. Defaults to "input". - // To define options for a select, use - element?: "input" | "select" | "textarea", + id?: string; // The field's type (when used as an ). Defaults to "text". - type?: string, + type?: string; // id of a element for suggestions - list?: string, + list?: string; // The field's label string. - label?: string, + label?: string; // The field's placeholder string. Defaults to the label. - placeholder?: string, - // The field's value. - // This is a controlled component, so the value is required. - value: string, + placeholder?: string; // Optional component to include inside the field before the input. - prefixComponent?: React.ReactNode, + prefixComponent?: React.ReactNode; // Optional component to include inside the field after the input. - postfixComponent?: React.ReactNode, + postfixComponent?: React.ReactNode; // The callback called whenever the contents of the field // changes. Returns an object with `valid` boolean field // and a `feedback` react component field to provide feedback // to the user. - onValidate?: (input: IFieldState) => Promise, + onValidate?: (input: IFieldState) => Promise; // If specified, overrides the value returned by onValidate. - flagInvalid?: boolean, + flagInvalid?: boolean; // If specified, contents will appear as a tooltip on the element and // validation feedback tooltips will be suppressed. - tooltipContent?: React.ReactNode, + tooltipContent?: React.ReactNode; // If specified alongside tooltipContent, the class name to apply to the // tooltip itself. - tooltipClassName?: string, + tooltipClassName?: string; // If specified, an additional class name to apply to the field container - className?: string, + className?: string; // All other props pass through to the . } -interface IState { - valid: boolean, - feedback: React.ReactNode, - feedbackVisible: boolean, - focused: boolean, +interface IInputProps extends IProps, InputHTMLAttributes { + // The element to create. Defaults to "input". + element?: "input"; + // The input's value. This is a controlled component, so the value is required. + value: string; } -export default class Field extends React.PureComponent { +interface ISelectProps extends IProps, SelectHTMLAttributes { + // To define options for a select, use + element: "select"; + // The select's value. This is a controlled component, so the value is required. + value: string; +} + +interface ITextareaProps extends IProps, TextareaHTMLAttributes { + element: "textarea"; + // The textarea's value. This is a controlled component, so the value is required. + value: string; +} + +type PropShapes = IInputProps | ISelectProps | ITextareaProps; + +interface IState { + valid: boolean; + feedback: React.ReactNode; + feedbackVisible: boolean; + focused: boolean; +} + +export default class Field extends React.PureComponent { private id: string; private input: HTMLInputElement; - private static defaultProps = { + public static readonly defaultProps = { element: "input", type: "text", - } + }; /* * This was changed from throttle to debounce: this is more traditional for diff --git a/src/components/views/elements/IRCTimelineProfileResizer.tsx b/src/components/views/elements/IRCTimelineProfileResizer.tsx index 596d46bf36..65140707d5 100644 --- a/src/components/views/elements/IRCTimelineProfileResizer.tsx +++ b/src/components/views/elements/IRCTimelineProfileResizer.tsx @@ -20,15 +20,15 @@ import Draggable, {ILocationState} from './Draggable'; interface IProps { // Current room - roomId: string, - minWidth: number, - maxWidth: number, -}; + roomId: string; + minWidth: number; + maxWidth: number; +} interface IState { - width: number, - IRCLayoutRoot: HTMLElement, -}; + width: number; + IRCLayoutRoot: HTMLElement; +} export default class IRCTimelineProfileResizer extends React.Component { constructor(props: IProps) { @@ -37,20 +37,19 @@ export default class IRCTimelineProfileResizer extends React.Component this.updateCSSWidth(this.state.width)) + }, () => this.updateCSSWidth(this.state.width)); } private dragFunc = (location: ILocationState, event: React.MouseEvent): ILocationState => { const offset = event.clientX - location.currentX; const newWidth = this.state.width + offset; - console.log({offset}) // If we're trying to go smaller than min width, don't. if (newWidth < this.props.minWidth) { return location; @@ -69,8 +68,8 @@ export default class IRCTimelineProfileResizer extends React.Component + return ; } -}; \ No newline at end of file +} diff --git a/src/components/views/elements/SettingsFlag.tsx b/src/components/views/elements/SettingsFlag.tsx index bda15ebaab..9bdd04d803 100644 --- a/src/components/views/elements/SettingsFlag.tsx +++ b/src/components/views/elements/SettingsFlag.tsx @@ -48,18 +48,18 @@ export default class SettingsFlag extends React.Component { this.props.roomId, this.props.isExplicit, ), - } + }; } private onChange = (checked: boolean): void => { this.save(checked); this.setState({ value: checked }); if (this.props.onChange) this.props.onChange(checked); - } + }; private checkBoxOnChange = (e: React.ChangeEvent) => { this.onChange(e.target.checked); - } + }; private save = (val?: boolean): void => { return SettingsStore.setValue( @@ -68,7 +68,7 @@ export default class SettingsFlag extends React.Component { this.props.level, val !== undefined ? val : this.state.value, ); - } + }; public render() { const canChange = SettingsStore.canSetValue(this.props.name, this.props.roomId, this.props.level); diff --git a/src/components/views/elements/Slider.tsx b/src/components/views/elements/Slider.tsx index f76a4684d3..a88c581d07 100644 --- a/src/components/views/elements/Slider.tsx +++ b/src/components/views/elements/Slider.tsx @@ -65,9 +65,9 @@ export default class Slider extends React.Component { const intervalWidth = 1 / (values.length - 1); - const linearInterpolation = (value - closestLessValue) / (closestGreaterValue - closestLessValue) + const linearInterpolation = (value - closestLessValue) / (closestGreaterValue - closestLessValue); - return 100 * (closest - 1 + linearInterpolation) * intervalWidth + return 100 * (closest - 1 + linearInterpolation) * intervalWidth; } @@ -87,7 +87,7 @@ export default class Slider extends React.Component { selection =

    -
    +
    ; } return
    @@ -115,13 +115,13 @@ export default class Slider extends React.Component { interface IDotProps { // Callback for behavior onclick - onClick: () => void, + onClick: () => void; // Whether the dot should appear active - active: boolean, + active: boolean; // The label on the dot - label: string, + label: string; // Whether the slider is disabled disabled: boolean; @@ -129,7 +129,7 @@ interface IDotProps { class Dot extends React.PureComponent { render(): React.ReactNode { - let className = "mx_Slider_dot" + let className = "mx_Slider_dot"; if (!this.props.disabled && this.props.active) { className += " mx_Slider_dotActive"; } diff --git a/src/components/views/elements/StyledCheckbox.tsx b/src/components/views/elements/StyledCheckbox.tsx index 341f59d5da..be983828ff 100644 --- a/src/components/views/elements/StyledCheckbox.tsx +++ b/src/components/views/elements/StyledCheckbox.tsx @@ -30,7 +30,7 @@ export default class StyledCheckbox extends React.PureComponent public static readonly defaultProps = { className: "", - } + }; constructor(props: IProps) { super(props); @@ -51,6 +51,6 @@ export default class StyledCheckbox extends React.PureComponent { this.props.children }
    - + ; } } \ No newline at end of file diff --git a/src/components/views/elements/StyledRadioButton.tsx b/src/components/views/elements/StyledRadioButton.tsx index 7d84f68c49..0ea786d953 100644 --- a/src/components/views/elements/StyledRadioButton.tsx +++ b/src/components/views/elements/StyledRadioButton.tsx @@ -26,16 +26,23 @@ interface IState { export default class StyledRadioButton extends React.PureComponent { public static readonly defaultProps = { className: '', - } + }; public render() { - const { children, className, ...otherProps } = this.props; - return