diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index e7f6ee1f84..02629ea169 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -34,7 +34,7 @@ src/components/views/rooms/LinkPreviewWidget.js src/components/views/rooms/MemberDeviceInfo.js src/components/views/rooms/MemberInfo.js src/components/views/rooms/MemberList.js -src/components/views/rooms/MessageComposer.js +src/components/views/rooms/SlateMessageComposer.js src/components/views/rooms/PinnedEventTile.js src/components/views/rooms/RoomList.js src/components/views/rooms/RoomPreviewBar.js @@ -50,7 +50,6 @@ src/components/views/settings/Notifications.js src/GroupAddressPicker.js src/HtmlUtils.js src/ImageUtils.js -src/languageHandler.js src/linkify-matrix.js src/Markdown.js src/MatrixClientPeg.js diff --git a/.stylelintrc.js b/.stylelintrc.js index 97e1ec8023..f028c76cc0 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -15,6 +15,9 @@ module.exports = { "number-leading-zero": null, "selector-list-comma-newline-after": null, "at-rule-no-unknown": null, - "scss/at-rule-no-unknown": true, + "scss/at-rule-no-unknown": [true, { + // https://github.com/vector-im/riot-web/issues/10544 + "ignoreAtRules": ["define-mixin"], + }], } } diff --git a/docs/ciderEditor.md b/docs/ciderEditor.md index 2448be852a..e67c74a95c 100644 --- a/docs/ciderEditor.md +++ b/docs/ciderEditor.md @@ -17,7 +17,7 @@ The parts are then reconciled with the DOM. When typing in the `contenteditable` element, the `input` event fires and the DOM of the editor is turned into a string. The way this is done has some logic to it to deal with adding newlines for block elements, to make sure -the caret offset is calculated in the same way as the content string, and the ignore +the caret offset is calculated in the same way as the content string, and to ignore caret nodes (more on that later). For these reasons it doesn't use `innerText`, `textContent` or anything similar. The model addresses any content in the editor within as an offset within this string. @@ -25,13 +25,13 @@ The caret position is thus also converted from a position in the DOM tree to an offset in the content string. This happens in `getCaretOffsetAndText` in `dom.js`. Once the content string and caret offset is calculated, it is passed to the `update()` -method of the model. The model first calculates the same content string its current parts, +method of the model. The model first calculates the same content string of its current parts, basically just concatenating their text. It then looks for differences between the current and the new content string. The diffing algorithm is very basic, and assumes there is only one change around the caret offset, so this should be very inexpensive. See `diff.js` for details. -The result of the diffing is the strings that was added and/or removed from +The result of the diffing is the strings that were added and/or removed from the current content. These differences are then applied to the parts, where parts can apply validation logic to these changes. @@ -48,7 +48,8 @@ to leave the parts it intersects alone. The benefit of this is that we can use the `input` event, which is broadly supported, to find changes in the editor. We don't have to rely on keyboard events, -which relate poorly to text input or changes. +which relate poorly to text input or changes, and don't need the `beforeinput` event, +which isn't broadly supported yet. Once the parts of the model are updated, the DOM of the editor is then reconciled with the new model state, see `renderModel` in `render.js` for this. diff --git a/karma.conf.js b/karma.conf.js index e2728cdc09..d55be049bb 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -28,7 +28,7 @@ process.env.PHANTOMJS_BIN = 'node_modules/.bin/phantomjs'; function fileExists(name) { try { - fs.statSync(gsCss); + fs.statSync(name); return true; } catch (e) { return false; @@ -166,7 +166,7 @@ module.exports = function (config) { ] }, { - test: /\.(gif|png|svg|ttf)$/, + test: /\.(gif|png|svg|ttf|woff2)$/, loader: 'file-loader', }, ], diff --git a/package.json b/package.json index 8e1a1fa668..70de250830 100644 --- a/package.json +++ b/package.json @@ -93,10 +93,10 @@ "qrcode-react": "^0.1.16", "qs": "^6.6.0", "querystring": "^0.2.0", - "react": "^15.6.0", - "react-addons-css-transition-group": "15.3.2", + "react": "^16.9.0", + "react-addons-css-transition-group": "15.6.2", "react-beautiful-dnd": "^4.0.1", - "react-dom": "^15.6.0", + "react-dom": "^16.9.0", "react-gemini-scrollbar": "github:matrix-org/react-gemini-scrollbar#f644523", "resize-observer-polyfill": "^1.5.0", "sanitize-html": "^1.18.4", @@ -148,9 +148,8 @@ "karma-summary-reporter": "^1.5.1", "karma-webpack": "^4.0.0-beta.0", "matrix-mock-request": "^1.2.3", - "matrix-react-test-utils": "^0.1.1", + "matrix-react-test-utils": "^0.2.2", "mocha": "^5.0.5", - "react-addons-test-utils": "^15.4.0", "require-json": "0.0.1", "rimraf": "^2.4.3", "sinon": "^5.0.7", diff --git a/res/css/_common.scss b/res/css/_common.scss index 517ced43fb..2b627cce9f 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -171,7 +171,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type=search], .mx_textinput { color: $input-darker-fg-color; - background-color: $input-darker-bg-color; + background-color: $primary-bg-color; border: none; } } @@ -330,7 +330,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { .mx_Dialog_header { position: relative; - margin-bottom: 20px; + margin-bottom: 10px; } .mx_Dialog_title { @@ -456,16 +456,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { background-color: $primary-bg-color; } -::-moz-selection { - background-color: $accent-color; - color: $selection-fg-color; -} - -::selection { - background-color: $accent-color; - color: $selection-fg-color; -} - .mx_textButton { @mixin mx_DialogButton_small; } @@ -559,3 +549,12 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { .mx_Username_color8 { color: $username-variant8-color; } + +@define-mixin mx_Settings_fullWidthField { + margin-right: 100px; +} + +@define-mixin mx_Settings_tooltip { + // So it fits in the space provided by the page + max-width: 120px; +} diff --git a/res/css/_components.scss b/res/css/_components.scss index dff174e943..213d0d714c 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -71,6 +71,7 @@ @import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_SlashCommandHelpDialog.scss"; +@import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; @import "./views/dialogs/_TermsDialog.scss"; @import "./views/dialogs/_UnknownDeviceDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; @@ -92,7 +93,6 @@ @import "./views/elements/_InteractiveTooltip.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_MemberEventListSummary.scss"; -@import "./views/elements/_MessageEditor.scss"; @import "./views/elements/_PowerSelector.scss"; @import "./views/elements/_ProgressBar.scss"; @import "./views/elements/_ReplyThread.scss"; @@ -135,7 +135,9 @@ @import "./views/rooms/_AppsDrawer.scss"; @import "./views/rooms/_Autocomplete.scss"; @import "./views/rooms/_AuxPanel.scss"; +@import "./views/rooms/_BasicMessageComposer.scss"; @import "./views/rooms/_E2EIcon.scss"; +@import "./views/rooms/_EditMessageComposer.scss"; @import "./views/rooms/_EntityTile.scss"; @import "./views/rooms/_EventTile.scss"; @import "./views/rooms/_JumpToBottomButton.scss"; @@ -144,6 +146,7 @@ @import "./views/rooms/_MemberInfo.scss"; @import "./views/rooms/_MemberList.scss"; @import "./views/rooms/_MessageComposer.scss"; +@import "./views/rooms/_MessageComposerFormatBar.scss"; @import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PinnedEventsPanel.scss"; @import "./views/rooms/_PresenceLabel.scss"; @@ -158,6 +161,7 @@ @import "./views/rooms/_RoomUpgradeWarningBar.scss"; @import "./views/rooms/_SearchBar.scss"; @import "./views/rooms/_SearchableEntityList.scss"; +@import "./views/rooms/_SendMessageComposer.scss"; @import "./views/rooms/_Stickers.scss"; @import "./views/rooms/_TopUnreadMessagesBar.scss"; @import "./views/rooms/_WhoIsTypingTile.scss"; @@ -168,6 +172,8 @@ @import "./views/settings/_Notifications.scss"; @import "./views/settings/_PhoneNumbers.scss"; @import "./views/settings/_ProfileSettings.scss"; +@import "./views/settings/_SetIdServer.scss"; +@import "./views/settings/_SetIntegrationManager.scss"; @import "./views/settings/tabs/_SettingsTab.scss"; @import "./views/settings/tabs/room/_GeneralRoomSettingsTab.scss"; @import "./views/settings/tabs/room/_RolesRoomSettingsTab.scss"; @@ -178,6 +184,7 @@ @import "./views/settings/tabs/user/_PreferencesUserSettingsTab.scss"; @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; +@import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/verification/_VerificationShowSas.scss"; @import "./views/voip/_CallView.scss"; @import "./views/voip/_IncomingCallbox.scss"; diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 7d10fdb6d6..85fdfa092d 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -125,3 +125,53 @@ limitations under the License. margin-top: 12px; } } + +.mx_LeftPanel_exploreAndFilterRow { + display: flex; + + .mx_SearchBox { + flex: 1 1 0; + min-width: 0; + margin: 4px 9px 1px 9px; + } +} + +.mx_LeftPanel_explore { + flex: 0 0 50%; + overflow: hidden; + transition: flex-basis 0.2s; + box-sizing: border-box; + + &.mx_LeftPanel_explore_hidden { + flex-basis: 0; + } + + .mx_AccessibleButton { + font-size: 14px; + margin: 4px 0 1px 9px; + padding: 9px; + padding-left: 42px; + font-weight: 600; + color: $notice-secondary-color; + position: relative; + border-radius: 4px; + + &:hover { + background-color: $primary-bg-color; + } + + &::before { + cursor: pointer; + mask: url('$(res)/img/explore.svg'); + mask-repeat: no-repeat; + mask-position: center center; + content: ""; + left: 14px; + top: 10px; + width: 16px; + height: 16px; + background-color: $notice-secondary-color; + position: absolute; + } + } +} diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index 1df0a61a2b..6b7a4ff0c7 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -17,7 +17,6 @@ limitations under the License. .mx_RoomDirectory_dialogWrapper > .mx_Dialog { max-width: 960px; height: 100%; - padding: 20px; } .mx_RoomDirectory_dialog { @@ -35,17 +34,6 @@ limitations under the License. flex: 1; } -.mx_RoomDirectory_createRoom { - background-color: $button-bg-color; - border-radius: 4px; - padding: 8px; - color: $button-fg-color; - font-weight: 600; - position: absolute; - top: 0; - left: 0; -} - .mx_RoomDirectory_list { flex: 1; display: flex; @@ -84,9 +72,8 @@ limitations under the License. } .mx_RoomDirectory_roomAvatar { - width: 24px; - padding-left: 12px; - padding-right: 24px; + width: 32px; + padding-right: 14px; vertical-align: top; } @@ -94,6 +81,34 @@ limitations under the License. padding-bottom: 16px; } +.mx_RoomDirectory_roomMemberCount { + color: $light-fg-color; + width: 60px; + padding: 0 10px; + text-align: center; + + &::before { + background-color: $light-fg-color; + display: inline-block; + vertical-align: text-top; + margin-right: 2px; + content: ""; + mask: url('$(res)/img/feather-customised/user.svg'); + mask-repeat: no-repeat; + mask-position: center; + // scale it down and make the size slightly bigger (16 instead of 14px) + // to avoid rendering artifacts + mask-size: 80%; + width: 16px; + height: 16px; + } +} + +.mx_RoomDirectory_join, .mx_RoomDirectory_preview { + width: 80px; + text-align: center; +} + .mx_RoomDirectory_name { display: inline-block; font-weight: 600; @@ -103,22 +118,9 @@ limitations under the License. display: inline-block; } -.mx_RoomDirectory_perm { - display: inline; - padding-left: 5px; - padding-right: 5px; - margin-right: 5px; - height: 15px; - border-radius: 11px; - background-color: $plinth-bg-color; - text-transform: uppercase; - font-weight: 600; - font-size: 11px; - color: $accent-color; -} - .mx_RoomDirectory_topic { cursor: initial; + color: $light-fg-color; } .mx_RoomDirectory_alias { @@ -126,13 +128,20 @@ limitations under the License. color: $settings-grey-fg-color; } -.mx_RoomDirectory_roomMemberCount { - text-align: right; - width: 100px; - padding-right: 10px; -} - .mx_RoomDirectory_table tr { padding-bottom: 10px; cursor: pointer; } + +.mx_RoomDirectory .mx_RoomView_MessageList { + padding: 0; +} + +.mx_RoomDirectory p { + font-size: 14px; + margin-top: 0; + + .mx_AccessibleButton { + padding: 0; + } +} diff --git a/res/css/structures/_SearchBox.scss b/res/css/structures/_SearchBox.scss index 9434d93bd2..23ee06f7b3 100644 --- a/res/css/structures/_SearchBox.scss +++ b/res/css/structures/_SearchBox.scss @@ -14,12 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_SearchBox_closeButton { - cursor: pointer; - background-image: url('$(res)/img/icons-close.svg'); - background-repeat: no-repeat; - width: 16px; - height: 16px; - background-position: center; - padding: 9px; +.mx_SearchBox { + flex: 1 1 0; + min-width: 0; + + &.mx_SearchBox_blurred:not(:hover) { + background-color: transparent; + } + + .mx_SearchBox_closeButton { + cursor: pointer; + background-image: url('$(res)/img/icons-close.svg'); + background-repeat: no-repeat; + width: 16px; + height: 16px; + background-position: center; + padding: 9px; + } } diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index 49a87d8077..b05629003e 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -39,8 +39,7 @@ limitations under the License. a:link, a:hover, a:visited { - color: $accent-color; - text-decoration: none; + @mixin mx_Dialog_link; } input[type=text], diff --git a/res/css/views/auth/_ServerConfig.scss b/res/css/views/auth/_ServerConfig.scss index a31feb75d7..a7e0057ab3 100644 --- a/res/css/views/auth/_ServerConfig.scss +++ b/res/css/views/auth/_ServerConfig.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019 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. @@ -14,23 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ServerConfig_fields { - display: flex; - margin: 1em 0; -} - -.mx_ServerConfig_fields .mx_Field { - margin: 0 5px; -} - -.mx_ServerConfig_fields .mx_Field:first-child { - margin-left: 0; -} - -.mx_ServerConfig_fields .mx_Field:last-child { - margin-right: 0; -} - .mx_ServerConfig_help:link { opacity: 0.8; } @@ -39,3 +23,13 @@ limitations under the License. display: block; color: $warning-color; } + +.mx_ServerConfig_identityServer { + transform: scaleY(0); + transform-origin: top; + transition: transform 0.25s; + + &.mx_ServerConfig_identityServer_shown { + transform: scaleY(1); + } +} diff --git a/res/css/views/dialogs/_AddressPickerDialog.scss b/res/css/views/dialogs/_AddressPickerDialog.scss index 2771ac4052..39a9260ba3 100644 --- a/res/css/views/dialogs/_AddressPickerDialog.scss +++ b/res/css/views/dialogs/_AddressPickerDialog.scss @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2019 New Vector Ltd +Copyright 2019 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. @@ -15,6 +16,14 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_AddressPickerDialog { + a:link, + a:hover, + a:visited { + @mixin mx_Dialog_link; + } +} + /* Using a textarea for this element, to circumvent autofill */ .mx_AddressPickerDialog_input, .mx_AddressPickerDialog_input:focus { @@ -67,3 +76,6 @@ limitations under the License. pointer-events: none; } +.mx_AddressPickerDialog_identityServer { + margin-top: 1em; +} diff --git a/res/css/views/dialogs/_TabbedIntegrationManagerDialog.scss b/res/css/views/dialogs/_TabbedIntegrationManagerDialog.scss new file mode 100644 index 0000000000..0ab59c44a7 --- /dev/null +++ b/res/css/views/dialogs/_TabbedIntegrationManagerDialog.scss @@ -0,0 +1,62 @@ +/* +Copyright 2019 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_TabbedIntegrationManagerDialog .mx_Dialog { + width: 60%; + height: 70%; + overflow: hidden; + padding: 0; + max-width: initial; + max-height: initial; + position: relative; +} + +.mx_TabbedIntegrationManagerDialog_container { + // Full size of the dialog, whatever it is + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + .mx_TabbedIntegrationManagerDialog_currentManager { + width: 100%; + height: 100%; + border-top: 1px solid $accent-color; + + iframe { + background-color: #fff; + border: 0; + width: 100%; + height: 100%; + } + } +} + +.mx_TabbedIntegrationManagerDialog_tab { + display: inline-block; + border: 1px solid $accent-color; + border-bottom: 0; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + padding: 10px 8px; + margin-right: 5px; +} + +.mx_TabbedIntegrationManagerDialog_currentTab { + background-color: $accent-color; + color: $accent-fg-color; +} diff --git a/res/css/views/elements/_Tooltip.scss b/res/css/views/elements/_Tooltip.scss index 8f6204c942..cc4eb409df 100644 --- a/res/css/views/elements/_Tooltip.scss +++ b/res/css/views/elements/_Tooltip.scss @@ -55,7 +55,7 @@ limitations under the License. border-radius: 4px; box-shadow: 4px 4px 12px 0 $menu-box-shadow-color; background-color: $menu-bg-color; - z-index: 2000; + z-index: 4000; // Higher than dialogs so tooltips can be used in dialogs padding: 10px; pointer-events: none; line-height: 14px; diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss new file mode 100644 index 0000000000..b32a44219a --- /dev/null +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -0,0 +1,76 @@ +/* +Copyright 2019 New Vector Ltd +Copyright 2019 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_BasicMessageComposer { + position: relative; + + .mx_BasicMessageComposer_inputEmpty > :first-child::before { + content: var(--placeholder); + opacity: 0.333; + width: 0; + height: 0; + overflow: visible; + display: inline-block; + pointer-events: none; + white-space: nowrap; + } + + @keyframes visualbell { + from { background-color: $visual-bell-bg-color; } + to { background-color: $primary-bg-color; } + } + + &.mx_BasicMessageComposer_input_error { + animation: 0.2s visualbell; + } + + .mx_BasicMessageComposer_input { + white-space: pre-wrap; + word-wrap: break-word; + outline: none; + overflow-x: auto; + + span.mx_UserPill, span.mx_RoomPill { + padding-left: 21px; + position: relative; + + // avatar psuedo element + &::before { + position: absolute; + left: 2px; + top: 2px; + content: var(--avatar-letter); + width: 16px; + height: 16px; + background: var(--avatar-background), $avatar-bg-color; + color: $avatar-initial-color; + background-repeat: no-repeat; + background-size: 16px; + border-radius: 8px; + text-align: center; + font-weight: normal; + line-height: 16px; + font-size: 10.4px; + } + } + } + + .mx_BasicMessageComposer_AutoCompleteWrapper { + position: relative; + height: 0; + } +} diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/rooms/_EditMessageComposer.scss similarity index 58% rename from res/css/views/elements/_MessageEditor.scss rename to res/css/views/rooms/_EditMessageComposer.scss index 7fd99bae17..214bfc4a1a 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/rooms/_EditMessageComposer.scss @@ -1,5 +1,6 @@ /* Copyright 2019 New Vector Ltd +Copyright 2019 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. @@ -14,8 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_MessageEditor { - border-radius: 4px; +.mx_EditMessageComposer { + padding: 3px; // this is to try not make the text move but still have some // padding around and in the editor. @@ -23,47 +24,20 @@ limitations under the License. margin: -7px -10px -5px -10px; overflow: visible !important; // override mx_EventTile_content - .mx_MessageEditor_editor { + + .mx_BasicMessageComposer_input { border-radius: 4px; border: solid 1px $primary-hairline-color; background-color: $primary-bg-color; - padding: 3px 6px; - white-space: pre-wrap; - word-wrap: break-word; - outline: none; max-height: 200px; - overflow-x: auto; + padding: 3px 6px; &:focus { border-color: $accent-color-50pct; } - - span.mx_UserPill, span.mx_RoomPill { - padding-left: 21px; - position: relative; - - // avatar psuedo element - &::before { - position: absolute; - left: 2px; - top: 2px; - content: var(--avatar-letter); - width: 16px; - height: 16px; - background: var(--avatar-background), $avatar-bg-color; - color: $avatar-initial-color; - background-repeat: no-repeat; - background-size: 16px; - border-radius: 8px; - text-align: center; - font-weight: normal; - line-height: 16px; - font-size: 10.4px; - } - } } - .mx_MessageEditor_buttons { + .mx_EditMessageComposer_buttons { display: flex; flex-direction: row; justify-content: flex-end; @@ -81,14 +55,9 @@ limitations under the License. padding: 5px 40px; } } - - .mx_MessageEditor_AutoCompleteWrapper { - position: relative; - height: 0; - } } -.mx_EventTile_last .mx_MessageEditor_buttons { +.mx_EventTile_last .mx_EditMessageComposer_buttons { position: static; margin-right: -147px; } diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 5e01c32147..fafd34f8ca 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -296,6 +296,25 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { overflow-y: hidden; } +/* Spoiler stuff */ +.mx_EventTile_spoiler { + cursor: pointer; +} + +.mx_EventTile_spoiler_reason { + color: $event-timestamp-color; + font-size: 11px; +} + +.mx_EventTile_spoiler_content { + filter: blur(5px) saturate(0.1) sepia(1); + transition-duration: 0.5s; +} + +.mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content { + filter: none; +} + .mx_EventTile_e2eIcon { display: block; position: absolute; diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index 6e17251cb0..5b4a9b764b 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -129,7 +129,7 @@ limitations under the License. } @keyframes visualbell { - from { background-color: #faa; } + from { background-color: $visual-bell-bg-color; } to { background-color: $primary-bg-color; } } diff --git a/res/css/views/rooms/_MessageComposerFormatBar.scss b/res/css/views/rooms/_MessageComposerFormatBar.scss new file mode 100644 index 0000000000..6e8fc58470 --- /dev/null +++ b/res/css/views/rooms/_MessageComposerFormatBar.scss @@ -0,0 +1,93 @@ +/* +Copyright 2019 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_MessageComposerFormatBar { + display: none; + width: calc(26px * 5); + height: 24px; + position: absolute; + cursor: pointer; + border-radius: 4px; + background-color: $message-action-bar-bg-color; + user-select: none; + + &.mx_MessageComposerFormatBar_shown { + display: block; + } + + > * { + white-space: nowrap; + display: inline-block; + position: relative; + border: 1px solid $message-action-bar-border-color; + margin-left: -1px; + + &:hover { + border-color: $message-action-bar-hover-border-color; + } + } + + .mx_MessageComposerFormatBar_button { + width: 27px; + height: 24px; + box-sizing: border-box; + } + + .mx_MessageComposerFormatBar_button::after { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + mask-repeat: no-repeat; + mask-position: center; + background-color: $message-action-bar-fg-color; + } + + .mx_MessageComposerFormatBar_buttonIconBold::after { + mask-image: url('$(res)/img/format/bold.svg'); + } + + .mx_MessageComposerFormatBar_buttonIconItalic::after { + mask-image: url('$(res)/img/format/italics.svg'); + } + + .mx_MessageComposerFormatBar_buttonIconStrikethrough::after { + mask-image: url('$(res)/img/format/strikethrough.svg'); + } + + .mx_MessageComposerFormatBar_buttonIconQuote::after { + mask-image: url('$(res)/img/format/quote.svg'); + } + + .mx_MessageComposerFormatBar_buttonIconCode::after { + mask-image: url('$(res)/img/format/code.svg'); + } +} + +.mx_MessageComposerFormatBar_buttonTooltip { + white-space: nowrap; + font-size: 13px; + font-weight: 600; + min-width: 54px; + text-align: center; + + .mx_MessageComposerFormatBar_tooltipShortcut { + font-size: 9px; + opacity: 0.7; + } +} diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss index b51d720e4d..5ed22f997d 100644 --- a/res/css/views/rooms/_RoomList.scss +++ b/res/css/views/rooms/_RoomList.scss @@ -27,10 +27,6 @@ limitations under the License. position: relative; } -.mx_SearchBox { - flex: none; -} - /* hide resize handles next to collapsed / empty sublists */ .mx_RoomList .mx_RoomSubList:not(.mx_RoomSubList_nonEmpty) + .mx_ResizeHandle { display: none; diff --git a/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss new file mode 100644 index 0000000000..d20f7107b3 --- /dev/null +++ b/res/css/views/rooms/_SendMessageComposer.scss @@ -0,0 +1,53 @@ +/* +Copyright 2019 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_SendMessageComposer { + flex: 1; + display: flex; + flex-direction: column; + font-size: 14px; + justify-content: center; + margin-right: 6px; + // don't grow wider than available space + min-width: 0; + + .mx_BasicMessageComposer { + flex: 1; + display: flex; + flex-direction: column; + // min-height at this level so the mx_BasicMessageComposer_input + // still stays vertically centered when less than 50px + min-height: 50px; + + .mx_BasicMessageComposer_input { + padding: 3px 0; + // this will center the contenteditable + // in it's parent vertically + // while keeping the autocomplete at the top + // of the composer. The parent needs to be a flex container for this to work. + margin: auto 0; + // max-height at this level so autocomplete doesn't get scrolled too + max-height: 140px; + overflow-y: auto; + } + } + + .mx_SendMessageComposer_overlayWrapper { + position: relative; + height: 0; + } +} + diff --git a/res/css/views/settings/_DevicesPanel.scss b/res/css/views/settings/_DevicesPanel.scss index 4113fc4ebc..581ff47fc1 100644 --- a/res/css/views/settings/_DevicesPanel.scss +++ b/res/css/views/settings/_DevicesPanel.scss @@ -26,8 +26,13 @@ limitations under the License. font-weight: bold; } +.mx_DevicesPanel_header > .mx_DevicesPanel_deviceButtons { + height: 48px; // make this tall so the table doesn't move down when the delete button appears +} + .mx_DevicesPanel_header > div { display: table-cell; + vertical-align: bottom; } .mx_DevicesPanel_header .mx_DevicesPanel_deviceLastSeen { diff --git a/res/css/views/settings/_EmailAddresses.scss b/res/css/views/settings/_EmailAddresses.scss index d7606ecea9..1c9ce724d1 100644 --- a/res/css/views/settings/_EmailAddresses.scss +++ b/res/css/views/settings/_EmailAddresses.scss @@ -1,5 +1,6 @@ /* Copyright 2019 New Vector Ltd +Copyright 2019 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. diff --git a/res/css/views/settings/_PhoneNumbers.scss b/res/css/views/settings/_PhoneNumbers.scss index 7aaef2a56b..507b07334e 100644 --- a/res/css/views/settings/_PhoneNumbers.scss +++ b/res/css/views/settings/_PhoneNumbers.scss @@ -1,5 +1,6 @@ /* Copyright 2019 New Vector Ltd +Copyright 2019 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. @@ -36,6 +37,15 @@ limitations under the License. margin-left: 5px; } +.mx_ExistingPhoneNumber_verification { + display: inline-flex; + align-items: center; + + .mx_Field { + margin: 0 0 0 1em; + } +} + .mx_PhoneNumbers_input { display: flex; align-items: center; diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss index 3e97a0ff6d..432b713c1b 100644 --- a/res/css/views/settings/_ProfileSettings.scss +++ b/res/css/views/settings/_ProfileSettings.scss @@ -43,7 +43,6 @@ limitations under the License. height: 88px; margin-left: 13px; position: relative; - cursor: pointer; } .mx_ProfileSettings_avatar > * { @@ -71,6 +70,7 @@ limitations under the License. text-align: center; vertical-align: middle; font-size: 10px; + cursor: pointer; } .mx_ProfileSettings_avatar:hover .mx_ProfileSettings_avatarOverlay:not(.mx_ProfileSettings_avatarOverlay_disabled) { diff --git a/res/css/views/settings/_SetIdServer.scss b/res/css/views/settings/_SetIdServer.scss new file mode 100644 index 0000000000..98c64b7218 --- /dev/null +++ b/res/css/views/settings/_SetIdServer.scss @@ -0,0 +1,23 @@ +/* +Copyright 2019 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_SetIdServer .mx_Field_input { + @mixin mx_Settings_fullWidthField; +} + +.mx_SetIdServer_tooltip { + @mixin mx_Settings_tooltip; +} diff --git a/res/css/views/settings/_SetIntegrationManager.scss b/res/css/views/settings/_SetIntegrationManager.scss new file mode 100644 index 0000000000..99537f9eb4 --- /dev/null +++ b/res/css/views/settings/_SetIntegrationManager.scss @@ -0,0 +1,37 @@ +/* +Copyright 2019 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_SetIntegrationManager .mx_Field_input { + @mixin mx_Settings_fullWidthField; +} + +.mx_SetIntegrationManager { + margin-top: 10px; + margin-bottom: 10px; +} + +.mx_SetIntegrationManager > .mx_SettingsTab_heading { + margin-bottom: 10px; +} + +.mx_SetIntegrationManager > .mx_SettingsTab_heading > .mx_SettingsTab_subheading { + display: inline-block; + padding-left: 5px; +} + +.mx_SetIntegrationManager_tooltip { + @mixin mx_Settings_tooltip; +} diff --git a/res/css/views/settings/tabs/_SettingsTab.scss b/res/css/views/settings/tabs/_SettingsTab.scss index 7755ee6053..794c8106be 100644 --- a/res/css/views/settings/tabs/_SettingsTab.scss +++ b/res/css/views/settings/tabs/_SettingsTab.scss @@ -24,6 +24,10 @@ limitations under the License. color: $primary-fg-color; } +.mx_SettingsTab_heading:nth-child(n + 2) { + margin-top: 30px; +} + .mx_SettingsTab_subheading { font-size: 16px; display: block; @@ -37,9 +41,8 @@ limitations under the License. .mx_SettingsTab_subsectionText { color: $settings-subsection-fg-color; font-size: 14px; - padding-bottom: 12px; display: block; - margin: 0 100px 0 0; // Align with the rest of the view + margin: 10px 100px 10px 0; // Align with the rest of the view } .mx_SettingsTab_section .mx_SettingsFlag { @@ -67,12 +70,6 @@ limitations under the License. word-break: break-all; } -.mx_SettingsTab .mx_SettingsTab_subheading:nth-child(n + 2) { - // These views have a lot of the same repetitive information on it, so - // give them more visual distinction between the sections. - margin-top: 30px; -} - .mx_SettingsTab a { color: $accent-color-alt; } diff --git a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss index 091c98ffb8..62d230e752 100644 --- a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss @@ -16,15 +16,21 @@ limitations under the License. .mx_GeneralUserSettingsTab_changePassword .mx_Field, .mx_GeneralUserSettingsTab_themeSection .mx_Field { - margin-right: 100px; // Align with the other fields on the page + @mixin mx_Settings_fullWidthField; } .mx_GeneralUserSettingsTab_changePassword .mx_Field:first-child { margin-top: 0; } -.mx_GeneralUserSettingsTab_accountSection > .mx_EmailAddresses, -.mx_GeneralUserSettingsTab_accountSection > .mx_PhoneNumbers, +.mx_GeneralUserSettingsTab_accountSection .mx_EmailAddresses, +.mx_GeneralUserSettingsTab_accountSection .mx_PhoneNumbers, +.mx_GeneralUserSettingsTab_discovery .mx_ExistingEmailAddress, +.mx_GeneralUserSettingsTab_discovery .mx_ExistingPhoneNumber, .mx_GeneralUserSettingsTab_languageInput { - margin-right: 100px; // Align with the other fields on the page + @mixin mx_Settings_fullWidthField; +} + +.mx_GeneralUserSettingsTab_warningIcon { + vertical-align: middle; } diff --git a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss index b3430f47af..d003e175d9 100644 --- a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss @@ -15,5 +15,5 @@ limitations under the License. */ .mx_PreferencesUserSettingsTab .mx_Field { - margin-right: 100px; // Align with the rest of the controls + @mixin mx_Settings_fullWidthField; } diff --git a/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss index 36c8cfd896..69d57bdba1 100644 --- a/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss @@ -15,7 +15,7 @@ limitations under the License. */ .mx_VoiceUserSettingsTab .mx_Field { - margin-right: 100px; // align with the rest of the fields + @mixin mx_Settings_fullWidthField; } .mx_VoiceUserSettingsTab_missingMediaPermissions { diff --git a/res/css/views/terms/_InlineTermsAgreement.scss b/res/css/views/terms/_InlineTermsAgreement.scss new file mode 100644 index 0000000000..e00dcf31d1 --- /dev/null +++ b/res/css/views/terms/_InlineTermsAgreement.scss @@ -0,0 +1,45 @@ +/* +Copyright 2019 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_InlineTermsAgreement_cbContainer { + margin-bottom: 10px; + font-size: 14px; + + a { + color: $accent-color; + text-decoration: none; + } + + .mx_InlineTermsAgreement_checkbox { + margin-top: 10px; + + input { + vertical-align: text-bottom; + } + } +} + +.mx_InlineTermsAgreement_link { + display: inline-block; + mask-image: url('$(res)/img/external-link.svg'); + background-color: $accent-color; + mask-repeat: no-repeat; + mask-size: contain; + width: 12px; + height: 12px; + margin-left: 3px; + vertical-align: middle; +} diff --git a/res/img/explore.svg b/res/img/explore.svg new file mode 100644 index 0000000000..3956e912ac --- /dev/null +++ b/res/img/explore.svg @@ -0,0 +1,97 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/res/img/format/bold.svg b/res/img/format/bold.svg new file mode 100644 index 0000000000..634d735031 --- /dev/null +++ b/res/img/format/bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/format/code.svg b/res/img/format/code.svg new file mode 100644 index 0000000000..0a29bcd7bd --- /dev/null +++ b/res/img/format/code.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/res/img/format/italics.svg b/res/img/format/italics.svg new file mode 100644 index 0000000000..841afadffd --- /dev/null +++ b/res/img/format/italics.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/format/quote.svg b/res/img/format/quote.svg new file mode 100644 index 0000000000..82d3403314 --- /dev/null +++ b/res/img/format/quote.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/format/strikethrough.svg b/res/img/format/strikethrough.svg new file mode 100644 index 0000000000..fc02b0aae2 --- /dev/null +++ b/res/img/format/strikethrough.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 90cd8e8558..ef0b91b41a 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -146,6 +146,8 @@ $button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color $button-link-fg-color: $accent-color; $button-link-bg-color: transparent; +$visual-bell-bg-color: #800; + $room-warning-bg-color: $header-panel-bg-color; $dark-panel-bg-color: $header-panel-bg-color; @@ -200,6 +202,11 @@ $interactive-tooltip-fg-color: #ffffff; background-color: $button-secondary-bg-color; } +@define-mixin mx_Dialog_link { + color: $accent-color; + text-decoration: none; +} + // Nasty hacks to apply a filter to arbitrary monochrome artwork to make it // better match the theme. Typically applied to dark grey 'off' buttons or // light grey 'on' buttons. diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index d8d4b0a11b..bfaac09761 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -247,6 +247,8 @@ $button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color $button-link-fg-color: $accent-color; $button-link-bg-color: transparent; +$visual-bell-bg-color: #faa; + // Toggle switch $togglesw-off-color: #c1c9d6; $togglesw-on-color: $accent-color; @@ -326,3 +328,8 @@ $interactive-tooltip-fg-color: #ffffff; color: $accent-color; background-color: $button-secondary-bg-color; } + +@define-mixin mx_Dialog_link { + color: $accent-color; + text-decoration: none; +} diff --git a/src/CallHandler.js b/src/CallHandler.js index 5b58400ae6..f6b3e18538 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018 New Vector Ltd +Copyright 2019 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. @@ -63,7 +64,8 @@ import SdkConfig from './SdkConfig'; import { showUnknownDeviceDialogForCalls } from './cryptodevices'; import WidgetUtils from './utils/WidgetUtils'; import WidgetEchoStore from './stores/WidgetEchoStore'; -import ScalarAuthClient from './ScalarAuthClient'; +import {IntegrationManagers} from "./integrations/IntegrationManagers"; +import SettingsStore, { SettingLevel } from './settings/SettingsStore'; global.mxCalls = { //room_id: MatrixCall @@ -117,8 +119,7 @@ function _reAttemptCall(call) { function _setCallListeners(call) { call.on("error", function(err) { - console.error("Call error: %s", err); - console.error(err.stack); + console.error("Call error:", err); if (err.code === 'unknown_devices') { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); @@ -146,8 +147,15 @@ function _setCallListeners(call) { }, }); } else { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + if ( + MatrixClientPeg.get().getTurnServers().length === 0 && + SettingsStore.getValue("fallbackICEServerAllowed") === null + ) { + _showICEFallbackPrompt(); + return; + } + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { title: _t('Call Failed'), description: err.message, @@ -217,6 +225,36 @@ function _setCallState(call, roomId, status) { }); } +function _showICEFallbackPrompt() { + const cli = MatrixClientPeg.get(); + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + const code = sub => {sub}; + Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, { + title: _t("Call failed due to misconfigured server"), + description:
+

{_t( + "Please ask the administrator of your homeserver " + + "(%(homeserverDomain)s) to configure a TURN server in " + + "order for calls to work reliably.", + { homeserverDomain: cli.getDomain() }, { code }, + )}

+

{_t( + "Alternatively, you can try to use the public server at " + + "turn.matrix.org, but this will not be as reliable, and " + + "it will share your IP address with that server. You can also manage " + + "this in Settings.", + null, { code }, + )}

+
, + button: _t('Try using turn.matrix.org'), + cancelButton: _t('OK'), + onFinished: (allow) => { + SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow); + cli.setFallbackICEServerAllowed(allow); + }, + }, null, true); +} + function _onAction(payload) { function placeCall(newCall) { _setCallListeners(newCall); @@ -348,14 +386,20 @@ async function _startCallApp(roomId, type) { // the state event in anyway, but the resulting widget would then not // work for us. Better that the user knows before everyone else in the // room sees it. - const scalarClient = new ScalarAuthClient(); - let haveScalar = false; - try { - await scalarClient.connect(); - haveScalar = scalarClient.hasCredentials(); - } catch (e) { - // fall through + const managers = IntegrationManagers.sharedInstance(); + let haveScalar = true; + if (managers.hasManager()) { + try { + const scalarClient = managers.getPrimaryManager().getScalarClient(); + await scalarClient.connect(); + haveScalar = scalarClient.hasCredentials(); + } catch (e) { + // ignore + } + } else { + haveScalar = false; } + if (!haveScalar) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -421,7 +465,8 @@ async function _startCallApp(roomId, type) { // URL, but this will at least allow the integration manager to not be hardcoded. widgetUrl = SdkConfig.get().integrations_jitsi_widget_url + '?' + queryString; } else { - widgetUrl = SdkConfig.get().integrations_rest_url + '/widgets/jitsi.html?' + queryString; + const apiUrl = IntegrationManagers.sharedInstance().getPrimaryManager().apiUrl; + widgetUrl = apiUrl + '/widgets/jitsi.html?' + queryString; } const widgetData = { widgetSessionId }; diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index d34e3d8ed0..8915c1412f 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -22,7 +22,8 @@ import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; import MatrixClientPeg from "./MatrixClientPeg"; import RoomViewStore from "./stores/RoomViewStore"; -import { showIntegrationsManager } from './integrations/integrations'; +import {IntegrationManagers} from "./integrations/IntegrationManagers"; +import SettingsStore from "./settings/SettingsStore"; const WIDGET_API_VERSION = '0.0.2'; // Current API version const SUPPORTED_WIDGET_API_VERSIONS = [ @@ -193,11 +194,20 @@ export default class FromWidgetPostMessageApi { const integType = (data && data.integType) ? data.integType : null; const integId = (data && data.integId) ? data.integId : null; - showIntegrationsManager({ - room: MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), - screen: 'type_' + integType, - integrationId: integId, - }); + // TODO: Open the right integration manager for the widget + if (SettingsStore.isFeatureEnabled("feature_many_integration_managers")) { + IntegrationManagers.sharedInstance().openAll( + MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), + `type_${integType}`, + integId, + ); + } else { + IntegrationManagers.sharedInstance().getPrimaryManager().open( + MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), + `type_${integType}`, + integId, + ); + } } else if (action === 'set_always_on_screen') { // This is a new message: there is no reason to support the deprecated widgetData here const data = event.data.data; diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js index cd5ecc790d..dfc90841a3 100644 --- a/src/GroupAddressPicker.js +++ b/src/GroupAddressPicker.js @@ -46,7 +46,7 @@ export function showGroupInviteDialog(groupId) { _onGroupInviteFinished(groupId, addrs).then(resolve, reject); }, - }); + }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); }); } @@ -81,7 +81,7 @@ export function showGroupAddRoomDialog(groupId) { _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly).then(resolve, reject); }, - }); + }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); }); } diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index aeaf95ddb7..6ede36ee81 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -256,7 +256,7 @@ const sanitizeHtmlParams = { allowedAttributes: { // custom ones first: font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix - span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix + span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix img: ['src', 'width', 'height', 'alt', 'title'], ol: ['start'], diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js index 755205d5e2..7cbad074bf 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.js @@ -14,15 +14,50 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { SERVICE_TYPES } from 'matrix-js-sdk'; +import { createClient, SERVICE_TYPES } from 'matrix-js-sdk'; import MatrixClientPeg from './MatrixClientPeg'; import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; export default class IdentityAuthClient { - constructor() { + /** + * Creates a new identity auth client + * @param {string} identityUrl The URL to contact the identity server with. + * When provided, this class will operate solely within memory, refusing to + * persist any information such as tokens. Default null (not provided). + */ + constructor(identityUrl = null) { this.accessToken = null; this.authEnabled = true; + + if (identityUrl) { + // XXX: We shouldn't have to create a whole new MatrixClient just to + // do identity server auth. The functions don't take an identity URL + // though, and making all of them take one could lead to developer + // confusion about what the idBaseUrl does on a client. Therefore, we + // just make a new client and live with it. + this.tempClient = createClient({ + baseUrl: "", // invalid by design + idBaseUrl: identityUrl, + }); + } else { + // Indicates that we're using the real client, not some workaround. + this.tempClient = null; + } + } + + get _matrixClient() { + return this.tempClient ? this.tempClient : MatrixClientPeg.get(); + } + + _writeToken() { + if (this.tempClient) return; // temporary client: ignore + window.localStorage.setItem("mx_is_access_token", this.accessToken); + } + + _readToken() { + if (this.tempClient) return null; // temporary client: ignore + return window.localStorage.getItem("mx_is_access_token"); } hasCredentials() { @@ -30,7 +65,7 @@ export default class IdentityAuthClient { } // Returns a promise that resolves to the access_token string from the IS - async getAccessToken() { + async getAccessToken({ check = true } = {}) { if (!this.authEnabled) { // The current IS doesn't support authentication return null; @@ -38,30 +73,32 @@ export default class IdentityAuthClient { let token = this.accessToken; if (!token) { - token = window.localStorage.getItem("mx_is_access_token"); + token = this._readToken(); } if (!token) { - token = await this.registerForToken(); + token = await this.registerForToken(check); if (token) { this.accessToken = token; - window.localStorage.setItem("mx_is_access_token", token); + this._writeToken(); } return token; } - try { - await this._checkToken(token); - } catch (e) { - if (e instanceof TermsNotSignedError) { - // Retrying won't help this - throw e; - } - // Retry in case token expired - token = await this.registerForToken(); - if (token) { - this.accessToken = token; - window.localStorage.setItem("mx_is_access_token", token); + if (check) { + try { + await this._checkToken(token); + } catch (e) { + if (e instanceof TermsNotSignedError) { + // Retrying won't help this + throw e; + } + // Retry in case token expired + token = await this.registerForToken(); + if (token) { + this.accessToken = token; + this._writeToken(); + } } } @@ -70,13 +107,13 @@ export default class IdentityAuthClient { async _checkToken(token) { try { - await MatrixClientPeg.get().getIdentityAccount(token); + await this._matrixClient.getIdentityAccount(token); } catch (e) { if (e.errcode === "M_TERMS_NOT_SIGNED") { console.log("Identity Server requires new terms to be agreed to"); await startTermsFlow([new Service( SERVICE_TYPES.IS, - MatrixClientPeg.get().idBaseUrl, + this._matrixClient.getIdentityServerUrl(), token, )]); return; @@ -91,12 +128,12 @@ export default class IdentityAuthClient { // See also https://github.com/vector-im/riot-web/issues/10455. } - async registerForToken() { + async registerForToken(check=true) { try { const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken(); const { access_token: identityAccessToken } = - await MatrixClientPeg.get().registerWithIdentityServer(hsOpenIdToken); - await this._checkToken(identityAccessToken); + await this._matrixClient.registerWithIdentityServer(hsOpenIdToken); + if (check) await this._checkToken(identityAccessToken); return identityAccessToken; } catch (e) { if (e.cors === "rejected" || e.httpStatus === 404) { diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 0ddb7e9aae..c03a958840 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -35,6 +35,7 @@ import { sendLoginRequest } from "./Login"; import * as StorageManager from './utils/StorageManager'; import SettingsStore from "./settings/SettingsStore"; import TypingStore from "./stores/TypingStore"; +import {IntegrationManagers} from "./integrations/IntegrationManagers"; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries @@ -580,6 +581,7 @@ async function startMatrixClient(startSyncing=true) { Presence.start(); } DMRoomMap.makeShared().start(); + IntegrationManagers.sharedInstance().startWatching(); ActiveWidgetStore.start(); if (startSyncing) { @@ -638,6 +640,7 @@ export function stopMatrixClient(unsetClient=true) { TypingStore.sharedInstance().reset(); Presence.stop(); ActiveWidgetStore.stop(); + IntegrationManagers.sharedInstance().stopWatching(); if (DMRoomMap.shared()) DMRoomMap.shared().stop(); const cli = MatrixClientPeg.get(); if (cli) { diff --git a/src/Login.js b/src/Login.js index c31a9308a8..d9ce8adaaa 100644 --- a/src/Login.js +++ b/src/Login.js @@ -2,6 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -87,32 +88,23 @@ export default class Login { const isEmail = username.indexOf("@") > 0; let identifier; - let legacyParams; // parameters added to support old HSes if (phoneCountry && phoneNumber) { identifier = { type: 'm.id.phone', country: phoneCountry, number: phoneNumber, }; - // No legacy support for phone number login } else if (isEmail) { identifier = { type: 'm.id.thirdparty', medium: 'email', address: username, }; - legacyParams = { - medium: 'email', - address: username, - }; } else { identifier = { type: 'm.id.user', user: username, }; - legacyParams = { - user: username, - }; } const loginParams = { @@ -120,7 +112,6 @@ export default class Login { identifier: identifier, initial_device_display_name: this._defaultDeviceDisplayName, }; - Object.assign(loginParams, legacyParams); const tryFallbackHs = (originalError) => { return sendLoginRequest( diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index d2760bc82c..27c4f40669 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -32,6 +32,7 @@ import Modal from './Modal'; import {verificationMethods} from 'matrix-js-sdk/lib/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; +import IdentityAuthClient from './IdentityAuthClient'; interface MatrixClientCreds { homeserverUrl: string, @@ -216,8 +217,10 @@ class MatrixClientPeg { deviceId: creds.deviceId, timelineSupport: true, forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false), + fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'), verificationMethods: [verificationMethods.SAS], unstableClientRelationAggregation: true, + identityServer: new IdentityAuthClient(), }; this.matrixClient = createMatrixClient(opts); diff --git a/src/Modal.js b/src/Modal.js index fd0fdc0501..26c9da8bbb 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -23,6 +23,7 @@ import Analytics from './Analytics'; import sdk from './index'; import dis from './dispatcher'; import { _t } from './languageHandler'; +import Promise from "bluebird"; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; @@ -182,7 +183,7 @@ class ModalManager { const modal = {}; // never call this from onFinished() otherwise it will loop - const closeDialog = this._getCloseFn(modal, props); + const [closeDialog, onFinishedProm] = this._getCloseFn(modal, props); // don't attempt to reuse the same AsyncWrapper for different dialogs, // otherwise we'll get confused. @@ -197,11 +198,13 @@ class ModalManager { modal.onFinished = props ? props.onFinished : null; modal.className = className; - return {modal, closeDialog}; + return {modal, closeDialog, onFinishedProm}; } _getCloseFn(modal, props) { - return (...args) => { + const deferred = Promise.defer(); + return [(...args) => { + deferred.resolve(args); if (props && props.onFinished) props.onFinished.apply(null, args); const i = this._modals.indexOf(modal); if (i >= 0) { @@ -223,7 +226,7 @@ class ModalManager { } this._reRender(); - }; + }, deferred.promise]; } /** @@ -256,7 +259,7 @@ class ModalManager { * @returns {object} Object with 'close' parameter being a function that will close the dialog */ createDialogAsync(prom, props, className, isPriorityModal, isStaticModal) { - const {modal, closeDialog} = this._buildModal(prom, props, className); + const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className); if (isPriorityModal) { // XXX: This is destructive @@ -269,15 +272,21 @@ class ModalManager { } this._reRender(); - return {close: closeDialog}; + return { + close: closeDialog, + finished: onFinishedProm, + }; } appendDialogAsync(prom, props, className) { - const {modal, closeDialog} = this._buildModal(prom, props, className); + const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className); this._modals.push(modal); this._reRender(); - return {close: closeDialog}; + return { + close: closeDialog, + finished: onFinishedProm, + }; } closeAll() { diff --git a/src/PasswordReset.js b/src/PasswordReset.js index df51e4d846..0dd5802962 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -36,7 +36,11 @@ class PasswordReset { idBaseUrl: identityUrl, }); this.clientSecret = this.client.generateClientSecret(); - this.identityServerDomain = identityUrl.split("://")[1]; + this.identityServerDomain = identityUrl ? identityUrl.split("://")[1] : null; + } + + doesServerRequireIdServerParam() { + return this.client.doesServerRequireIdServerParam(); } /** diff --git a/src/RoomInvite.js b/src/RoomInvite.js index b2382e206f..ec99cd8e9a 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -51,11 +51,18 @@ export function showStartChatInviteDialog() { Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, { title: _t('Start a chat'), description: _t("Who would you like to communicate with?"), - placeholder: _t("Email, name or Matrix ID"), + placeholder: (validAddressTypes) => { + // The set of valid address type can be mutated inside the dialog + // when you first have no IS but agree to use one in the dialog. + if (validAddressTypes.includes('email')) { + return _t("Email, name or Matrix ID"); + } + return _t("Name or Matrix ID"); + }, validAddressTypes, button: _t("Start Chat"), onFinished: _onStartDmFinished, - }); + }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); } export function showRoomInviteDialog(roomId) { @@ -68,14 +75,20 @@ export function showRoomInviteDialog(roomId) { Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, { title: _t('Invite new room members'), - description: _t('Who would you like to add to this room?'), button: _t('Send Invites'), - placeholder: _t("Email, name or Matrix ID"), + placeholder: (validAddressTypes) => { + // The set of valid address type can be mutated inside the dialog + // when you first have no IS but agree to use one in the dialog. + if (validAddressTypes.includes('email')) { + return _t("Email, name or Matrix ID"); + } + return _t("Name or Matrix ID"); + }, validAddressTypes, onFinished: (shouldInvite, addrs) => { _onRoomInviteFinished(roomId, shouldInvite, addrs); }, - }); + }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); } /** diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index c268fbe3fb..3623d47f8e 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -29,20 +29,43 @@ import * as Matrix from 'matrix-js-sdk'; // The version of the integration manager API we're intending to work with const imApiVersion = "1.1"; -class ScalarAuthClient { - constructor() { +export default class ScalarAuthClient { + constructor(apiUrl, uiUrl) { + this.apiUrl = apiUrl; + this.uiUrl = uiUrl; this.scalarToken = null; // `undefined` to allow `startTermsFlow` to fallback to a default // callback if this is unset. this.termsInteractionCallback = undefined; + + // We try and store the token on a per-manager basis, but need a fallback + // for the default manager. + const configApiUrl = SdkConfig.get()['integrations_rest_url']; + const configUiUrl = SdkConfig.get()['integrations_ui_url']; + this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl; } - /** - * Determines if setting up a ScalarAuthClient is even possible - * @returns {boolean} true if possible, false otherwise. - */ - static isPossible() { - return SdkConfig.get()['integrations_rest_url'] && SdkConfig.get()['integrations_ui_url']; + _writeTokenToStore() { + window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken); + if (this.isDefaultManager) { + // We remove the old token from storage to migrate upwards. This is safe + // to do because even if the user switches to /app when this is on /develop + // they'll at worst register for a new token. + window.localStorage.removeItem("mx_scalar_token"); // no-op when not present + } + } + + _readTokenFromStore() { + let token = window.localStorage.getItem("mx_scalar_token_at_" + this.apiUrl); + if (!token && this.isDefaultManager) { + token = window.localStorage.getItem("mx_scalar_token"); + } + return token; + } + + _readToken() { + if (this.scalarToken) return this.scalarToken; + return this._readTokenFromStore(); } setTermsInteractionCallback(callback) { @@ -61,8 +84,7 @@ class ScalarAuthClient { // Returns a promise that resolves to a scalar_token string getScalarToken() { - let token = this.scalarToken; - if (!token) token = window.localStorage.getItem("mx_scalar_token"); + const token = this._readToken(); if (!token) { return this.registerForToken(); @@ -78,7 +100,7 @@ class ScalarAuthClient { } _getAccountName(token) { - const url = SdkConfig.get().integrations_rest_url + "/account"; + const url = this.apiUrl + "/account"; return new Promise(function(resolve, reject) { request({ @@ -111,7 +133,7 @@ class ScalarAuthClient { return token; }).catch((e) => { if (e instanceof TermsNotSignedError) { - console.log("Integrations manager requires new terms to be agreed to"); + console.log("Integration manager requires new terms to be agreed to"); // The terms endpoints are new and so live on standard _matrix prefixes, // but IM rest urls are currently configured with paths, so remove the // path from the base URL before passing it to the js-sdk @@ -126,7 +148,7 @@ class ScalarAuthClient { // Once we've fully transitioned to _matrix URLs, we can give people // a grace period to update their configs, then use the rest url as // a regular base url. - const parsedImRestUrl = url.parse(SdkConfig.get().integrations_rest_url); + const parsedImRestUrl = url.parse(this.apiUrl); parsedImRestUrl.path = ''; parsedImRestUrl.pathname = ''; return startTermsFlow([new Service( @@ -147,17 +169,18 @@ class ScalarAuthClient { return MatrixClientPeg.get().getOpenIdToken().then((tokenObject) => { // Now we can send that to scalar and exchange it for a scalar token return this.exchangeForScalarToken(tokenObject); - }).then((tokenObject) => { + }).then((token) => { // Validate it (this mostly checks to see if the IM needs us to agree to some terms) - return this._checkToken(tokenObject); - }).then((tokenObject) => { - window.localStorage.setItem("mx_scalar_token", tokenObject); - return tokenObject; + return this._checkToken(token); + }).then((token) => { + this.scalarToken = token; + this._writeTokenToStore(); + return token; }); } exchangeForScalarToken(openidTokenObject) { - const scalarRestUrl = SdkConfig.get().integrations_rest_url; + const scalarRestUrl = this.apiUrl; return new Promise(function(resolve, reject) { request({ @@ -181,7 +204,7 @@ class ScalarAuthClient { } getScalarPageTitle(url) { - let scalarPageLookupUrl = SdkConfig.get().integrations_rest_url + '/widgets/title_lookup'; + let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup'; scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl); scalarPageLookupUrl += '&curl=' + encodeURIComponent(url); @@ -217,7 +240,7 @@ class ScalarAuthClient { * @return {Promise} Resolves on completion */ disableWidgetAssets(widgetType, widgetId) { - let url = SdkConfig.get().integrations_rest_url + '/widgets/set_assets_state'; + let url = this.apiUrl + '/widgets/set_assets_state'; url = this.getStarterLink(url); return new Promise((resolve, reject) => { request({ @@ -246,7 +269,7 @@ class ScalarAuthClient { getScalarInterfaceUrlForRoom(room, screen, id) { const roomId = room.roomId; const roomName = room.name; - let url = SdkConfig.get().integrations_ui_url; + let url = this.uiUrl; url += "?scalar_token=" + encodeURIComponent(this.scalarToken); url += "&room_id=" + encodeURIComponent(roomId); url += "&room_name=" + encodeURIComponent(roomName); @@ -264,5 +287,3 @@ class ScalarAuthClient { return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken); } } - -module.exports = ScalarAuthClient; diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 8b87650929..910a6c4f13 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -232,13 +232,13 @@ Example: } */ -import SdkConfig from './SdkConfig'; import MatrixClientPeg from './MatrixClientPeg'; import { MatrixEvent } from 'matrix-js-sdk'; import dis from './dispatcher'; import WidgetUtils from './utils/WidgetUtils'; import RoomViewStore from './stores/RoomViewStore'; import { _t } from './languageHandler'; +import {IntegrationManagers} from "./integrations/IntegrationManagers"; function sendResponse(event, res) { const data = JSON.parse(JSON.stringify(event.data)); @@ -548,7 +548,8 @@ const onMessage = function(event) { // (See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) let configUrl; try { - configUrl = new URL(SdkConfig.get().integrations_ui_url); + if (!openManagerUrl) openManagerUrl = IntegrationManagers.sharedInstance().getPrimaryManager().uiUrl; + configUrl = new URL(openManagerUrl); } catch (e) { // No integrations UI URL, ignore silently. return; @@ -656,6 +657,7 @@ const onMessage = function(event) { }; let listenerCount = 0; +let openManagerUrl = null; module.exports = { startListening: function() { if (listenerCount === 0) { @@ -678,4 +680,8 @@ module.exports = { console.error(e); } }, + + setOpenManagerUrl: function(url) { + openManagerUrl = url; + }, }; diff --git a/src/SendHistoryManager.js b/src/SendHistoryManager.js new file mode 100644 index 0000000000..794a58ad6f --- /dev/null +++ b/src/SendHistoryManager.js @@ -0,0 +1,60 @@ +//@flow +/* +Copyright 2017 Aviral Dasgupta + +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 _clamp from 'lodash/clamp'; + +export default class SendHistoryManager { + history: Array = []; + prefix: string; + lastIndex: number = 0; // used for indexing the storage + currentIndex: number = 0; // used for indexing the loaded validated history Array + + constructor(roomId: string, prefix: string) { + this.prefix = prefix + roomId; + + // TODO: Performance issues? + let index = 0; + let itemJSON; + + while (itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`)) { + try { + const serializedParts = JSON.parse(itemJSON); + this.history.push(serializedParts); + } catch (e) { + console.warn("Throwing away unserialisable history", e); + break; + } + ++index; + } + this.lastIndex = this.history.length - 1; + // reset currentIndex to account for any unserialisable history + this.currentIndex = this.lastIndex + 1; + } + + save(editorModel: Object) { + const serializedParts = editorModel.serializeParts(); + this.history.push(serializedParts); + this.currentIndex = this.history.length; + this.lastIndex += 1; + sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(serializedParts)); + } + + getItem(offset: number): ?HistoryItem { + this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1); + return this.history[this.currentIndex]; + } +} diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 72ace22cb6..2d5617f8f0 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -31,6 +31,9 @@ import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import WidgetUtils from "./utils/WidgetUtils"; import {textToHtmlRainbow} from "./utils/colour"; import Promise from "bluebird"; +import { getAddressType } from './UserAddress'; +import { abbreviateUrl } from './utils/UrlUtils'; +import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils'; const singleMxcUpload = async () => { return new Promise((resolve) => { @@ -115,7 +118,15 @@ export const CommandMap = { }, category: CommandCategories.messages, }), - + plain: new Command({ + name: 'plain', + args: '', + description: _td('Sends a message as plain text, without interpreting it as markdown'), + runFn: function(roomId, messages) { + return success(MatrixClientPeg.get().sendTextMessage(roomId, messages)); + }, + category: CommandCategories.messages, + }), ddg: new Command({ name: 'ddg', args: '', @@ -139,8 +150,13 @@ export const CommandMap = { description: _td('Upgrades a room to a new version'), runFn: function(roomId, args) { if (args) { - const room = MatrixClientPeg.get().getRoom(roomId); - Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(roomId); + if (!room.currentState.mayClientSendStateEvent("m.room.tombstone", cli)) { + return reject(_t("You do not have the required permissions to use this command.")); + } + + const {finished} = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', QuestionDialog, { title: _t('Room upgrade confirmation'), description: ( @@ -198,13 +214,13 @@ export const CommandMap = { ), button: _t("Upgrade"), - onFinished: (confirm) => { - if (!confirm) return; - - MatrixClientPeg.get().upgradeRoom(roomId, args); - }, }); - return success(); + + return success(finished.then((confirm) => { + if (!confirm) return; + + return cli.upgradeRoom(roomId, args); + })); } return reject(this.getUsage()); }, @@ -337,11 +353,46 @@ export const CommandMap = { if (matches) { // We use a MultiInviter to re-use the invite logic, even though // we're only inviting one user. - const userId = matches[1]; + const address = matches[1]; + // If we need an identity server but don't have one, things + // get a bit more complex here, but we try to show something + // meaningful. + let finished = Promise.resolve(); + if ( + getAddressType(address) === 'email' && + !MatrixClientPeg.get().getIdentityServerUrl() + ) { + const defaultIdentityServerUrl = getDefaultIdentityServerUrl(); + if (defaultIdentityServerUrl) { + ({ finished } = Modal.createTrackedDialog('Slash Commands', 'Identity server', + QuestionDialog, { + title: _t("Use an identity server"), + description:

{_t( + "Use an identity server to invite by email. " + + "Click continue to use the default identity server " + + "(%(defaultIdentityServerName)s) or manage in Settings.", + { + defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl), + }, + )}

, + button: _t("Continue"), + }, + )); + } else { + return reject(_t("Use an identity server to invite by email. Manage in Settings.")); + } + } const inviter = new MultiInviter(roomId); - return success(inviter.invite([userId]).then(() => { - if (inviter.getCompletionState(userId) !== "invited") { - throw new Error(inviter.getErrorText(userId)); + return success(finished.then(([useDefault] = []) => { + if (useDefault) { + useDefaultIdentityServer(); + } else if (useDefault === false) { + throw new Error(_t("Use an identity server to invite by email. Manage in Settings.")); + } + return inviter.invite([address]); + }).then(() => { + if (inviter.getCompletionState(address) !== "invited") { + throw new Error(inviter.getErrorText(address)); } })); } diff --git a/src/ComposerHistoryManager.js b/src/SlateComposerHistoryManager.js similarity index 98% rename from src/ComposerHistoryManager.js rename to src/SlateComposerHistoryManager.js index 1b3fb588eb..948dcf64ff 100644 --- a/src/ComposerHistoryManager.js +++ b/src/SlateComposerHistoryManager.js @@ -47,7 +47,7 @@ class HistoryItem { } } -export default class ComposerHistoryManager { +export default class SlateComposerHistoryManager { history: Array = []; prefix: string; lastIndex: number = 0; // used for indexing the storage diff --git a/src/Terms.js b/src/Terms.js index 02e34cbb3f..685a39709c 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -116,16 +116,21 @@ export async function startTermsFlow( } // if there's anything left to agree to, prompt the user + const numAcceptedBeforeAgreement = agreedUrlSet.size; if (unagreedPoliciesAndServicePairs.length > 0) { const newlyAgreedUrls = await interactionCallback(unagreedPoliciesAndServicePairs, [...agreedUrlSet]); console.log("User has agreed to URLs", newlyAgreedUrls); - agreedUrlSet = new Set(newlyAgreedUrls); + // Merge with previously agreed URLs + newlyAgreedUrls.forEach(url => agreedUrlSet.add(url)); } else { console.log("User has already agreed to all required policies"); } - const newAcceptedTerms = { accepted: Array.from(agreedUrlSet) }; - await MatrixClientPeg.get().setAccountData('m.accepted_terms', newAcceptedTerms); + // We only ever add to the set of URLs, so if anything has changed then we'd see a different length + if (agreedUrlSet.size !== numAcceptedBeforeAgreement) { + const newAcceptedTerms = {accepted: Array.from(agreedUrlSet)}; + await MatrixClientPeg.get().setAccountData('m.accepted_terms', newAcceptedTerms); + } const agreePromises = policiesAndServicePairs.map((policiesAndService) => { // filter the agreed URL list for ones that are actually for this service diff --git a/src/async-components/views/dialogs/EncryptedEventDialog.js b/src/async-components/views/dialogs/EncryptedEventDialog.js index 5db8b2365f..145203136a 100644 --- a/src/async-components/views/dialogs/EncryptedEventDialog.js +++ b/src/async-components/views/dialogs/EncryptedEventDialog.js @@ -15,12 +15,13 @@ limitations under the License. */ const React = require("react"); +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; const sdk = require('../../../index'); const MatrixClientPeg = require("../../../MatrixClientPeg"); -module.exports = React.createClass({ +module.exports = createReactClass({ displayName: 'EncryptedEventDialog', propTypes: { diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js index 529780c121..0fd412935a 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -17,6 +17,7 @@ limitations under the License. import FileSaver from 'file-saver'; import React from 'react'; import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; import { _t } from '../../../languageHandler'; import { MatrixClient } from 'matrix-js-sdk'; @@ -26,7 +27,7 @@ import sdk from '../../../index'; const PHASE_EDIT = 1; const PHASE_EXPORTING = 2; -export default React.createClass({ +export default createReactClass({ displayName: 'ExportE2eKeysDialog', propTypes: { diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js index 5181b6da2f..17f3bba117 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -16,6 +16,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; import { MatrixClient } from 'matrix-js-sdk'; import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; @@ -37,7 +38,7 @@ function readFileAsArrayBuffer(file) { const PHASE_EDIT = 1; const PHASE_IMPORTING = 2; -export default React.createClass({ +export default createReactClass({ displayName: 'ImportE2eKeysDialog', propTypes: { diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index 9ceff69467..e36763591e 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import sdk from '../../../../index'; import MatrixClientPeg from '../../../../MatrixClientPeg'; import { scorePassword } from '../../../../utils/PasswordScorer'; @@ -48,7 +49,7 @@ function selectText(target) { * Walks the user through the process of creating an e2e key backup * on the server. */ -export default React.createClass({ +export default createReactClass({ getInitialState: function() { return { phase: PHASE_PASSPHRASE, diff --git a/src/boundThreepids.js b/src/boundThreepids.js new file mode 100644 index 0000000000..3b32815913 --- /dev/null +++ b/src/boundThreepids.js @@ -0,0 +1,58 @@ +/* +Copyright 2019 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 IdentityAuthClient from './IdentityAuthClient'; + +export async function getThreepidsWithBindStatus(client, filterMedium) { + const userId = client.getUserId(); + + let { threepids } = await client.getThreePids(); + if (filterMedium) { + threepids = threepids.filter((a) => a.medium === filterMedium); + } + + // Check bind status assuming we have an IS and terms are agreed + if (threepids.length > 0 && !!client.getIdentityServerUrl()) { + try { + const authClient = new IdentityAuthClient(); + const identityAccessToken = await authClient.getAccessToken({ check: false }); + + // Restructure for lookup query + const query = threepids.map(({ medium, address }) => [medium, address]); + const lookupResults = await client.bulkLookupThreePids(query, identityAccessToken); + + // Record which are already bound + for (const [medium, address, mxid] of lookupResults.threepids) { + if (mxid !== userId) { + continue; + } + if (filterMedium && medium !== filterMedium) { + continue; + } + const threepid = threepids.find(e => e.medium === medium && e.address === address); + if (!threepid) continue; + threepid.bound = true; + } + } catch (e) { + // Ignore terms errors here and assume other flows handle this + if (!(e.errcode === "M_TERMS_NOT_SIGNED")) { + throw e; + } + } + } + + return threepids; +} diff --git a/src/components/structures/CompatibilityPage.js b/src/components/structures/CompatibilityPage.js index 9241f9e1f4..28c86f8dd8 100644 --- a/src/components/structures/CompatibilityPage.js +++ b/src/components/structures/CompatibilityPage.js @@ -16,10 +16,11 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { _t } from '../../languageHandler'; -module.exports = React.createClass({ +module.exports = createReactClass({ displayName: 'CompatibilityPage', propTypes: { onAccept: PropTypes.func, diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index e35a39a107..2b9594581e 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import Matrix from 'matrix-js-sdk'; @@ -25,7 +26,7 @@ import { _t } from '../../languageHandler'; /* * Component which shows the filtered file using a TimelinePanel */ -const FilePanel = React.createClass({ +const FilePanel = createReactClass({ displayName: 'FilePanel', propTypes: { diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index d5fa8fa5ae..70d8b2e298 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -17,6 +17,7 @@ limitations under the License. */ import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import Promise from 'bluebird'; import MatrixClientPeg from '../../MatrixClientPeg'; @@ -67,7 +68,7 @@ const UserSummaryType = PropTypes.shape({ }).isRequired, }); -const CategoryRoomList = React.createClass({ +const CategoryRoomList = createReactClass({ displayName: 'CategoryRoomList', props: { @@ -119,7 +120,7 @@ const CategoryRoomList = React.createClass({ }); }); }, - }); + }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); }, render: function() { @@ -156,7 +157,7 @@ const CategoryRoomList = React.createClass({ }, }); -const FeaturedRoom = React.createClass({ +const FeaturedRoom = createReactClass({ displayName: 'FeaturedRoom', props: { @@ -244,7 +245,7 @@ const FeaturedRoom = React.createClass({ }, }); -const RoleUserList = React.createClass({ +const RoleUserList = createReactClass({ displayName: 'RoleUserList', props: { @@ -296,7 +297,7 @@ const RoleUserList = React.createClass({ }); }); }, - }); + }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); }, render: function() { @@ -327,7 +328,7 @@ const RoleUserList = React.createClass({ }, }); -const FeaturedUser = React.createClass({ +const FeaturedUser = createReactClass({ displayName: 'FeaturedUser', props: { @@ -399,7 +400,7 @@ const FeaturedUser = React.createClass({ const GROUP_JOINPOLICY_OPEN = "open"; const GROUP_JOINPOLICY_INVITE = "invite"; -export default React.createClass({ +export default createReactClass({ displayName: 'GroupView', propTypes: { diff --git a/src/components/structures/IndicatorScrollbar.js b/src/components/structures/IndicatorScrollbar.js index d6efe8bee2..f14d99f730 100644 --- a/src/components/structures/IndicatorScrollbar.js +++ b/src/components/structures/IndicatorScrollbar.js @@ -19,7 +19,7 @@ import PropTypes from "prop-types"; import AutoHideScrollbar from "./AutoHideScrollbar"; export default class IndicatorScrollbar extends React.Component { - static PropTypes = { + static propTypes = { // If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator // and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning // by the parent element. diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index ccc906601c..5e06d124c4 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -19,13 +19,14 @@ import Matrix from 'matrix-js-sdk'; const InteractiveAuth = Matrix.InteractiveAuth; import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import {getEntryComponentForLoginType} from '../views/auth/InteractiveAuthEntryComponents'; import sdk from '../../index'; -export default React.createClass({ +export default createReactClass({ displayName: 'InteractiveAuth', propTypes: { diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index 2581319d75..fd315d2540 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -14,9 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import React from 'react'; +import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { MatrixClient } from 'matrix-js-sdk'; @@ -30,7 +29,7 @@ import {_t} from "../../languageHandler"; import Analytics from "../../Analytics"; -const LeftPanel = React.createClass({ +const LeftPanel = createReactClass({ displayName: 'LeftPanel', // NB. If you add props, don't forget to update @@ -82,6 +81,9 @@ const LeftPanel = React.createClass({ if (this.state.searchFilter !== nextState.searchFilter) { return true; } + if (this.state.searchExpanded !== nextState.searchExpanded) { + return true; + } return false; }, @@ -204,12 +206,23 @@ const LeftPanel = React.createClass({ if (source === "keyboard") { dis.dispatch({action: 'focus_composer'}); } + this.setState({searchExpanded: false}); }, collectRoomList: function(ref) { this._roomList = ref; }, + _onSearchFocus: function() { + this.setState({searchExpanded: true}); + }, + + _onSearchBlur: function(event) { + if (event.target.value.length === 0) { + this.setState({searchExpanded: false}); + } + }, + render: function() { const RoomList = sdk.getComponent('rooms.RoomList'); const RoomBreadcrumbs = sdk.getComponent('rooms.RoomBreadcrumbs'); @@ -218,6 +231,7 @@ const LeftPanel = React.createClass({ const TopLeftMenuButton = sdk.getComponent('structures.TopLeftMenuButton'); const SearchBox = sdk.getComponent('structures.SearchBox'); const CallPreview = sdk.getComponent('voip.CallPreview'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const tagPanelEnabled = SettingsStore.getValue("TagPanel.enableTagPanel"); let tagPanelContainer; @@ -241,11 +255,23 @@ const LeftPanel = React.createClass({ }, ); + let exploreButton; + if (!this.props.collapsed) { + exploreButton = ( +
+ dis.dispatch({action: 'view_room_directory'})}>{_t("Explore")} +
+ ); + } + const searchBox = (); let breadcrumbs; @@ -259,7 +285,10 @@ const LeftPanel = React.createClass({