diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml index 946e417676..af55fe8cb4 100644 --- a/.buildkite/pipeline.yaml +++ b/.buildkite/pipeline.yaml @@ -1,7 +1,12 @@ steps: - label: ":eslint: Lint" command: - - "yarn install" + # TODO: Remove hacky chmod for BuildKite + - "echo '--- Setup'" + - "chmod +x ./scripts/ci/*.sh" + - "chmod +x ./scripts/*" + - "echo '--- Install js-sdk'" + - "./scripts/ci/install-deps.sh" - "yarn lintwithexclusions" - "yarn stylelint" plugins: @@ -80,6 +85,16 @@ steps: image: "node:10" propagate-environment: true + - label: "🌐 i18n" + command: + - "echo '--- Fetching Dependencies'" + - "yarn install" + - "echo '+++ Testing i18n output'" + - "yarn diff-i18n" + plugins: + - docker#v3.0.1: + image: "node:10" + - wait - label: "🐴 Trigger riot-web" diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 3636a5e563..02629ea169 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -3,7 +3,6 @@ src/component-index.js src/components/structures/BottomLeftMenu.js src/components/structures/CreateRoom.js -src/components/structures/MessagePanel.js src/components/structures/RoomDirectory.js src/components/structures/RoomStatusBar.js src/components/structures/RoomView.js @@ -18,7 +17,6 @@ src/components/views/dialogs/SetPasswordDialog.js src/components/views/dialogs/UnknownDeviceDialog.js src/components/views/elements/AddressSelector.js src/components/views/elements/DirectorySearchBox.js -src/components/views/elements/ImageView.js src/components/views/elements/MemberEventListSummary.js src/components/views/elements/TintableSvg.js src/components/views/elements/UserSelector.js @@ -36,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 @@ -48,12 +46,10 @@ src/components/views/rooms/UserTile.js src/components/views/settings/ChangeAvatar.js src/components/views/settings/ChangePassword.js src/components/views/settings/DevicesPanel.js -src/components/views/settings/IntegrationsManager.js 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 @@ -68,7 +64,6 @@ src/rageshake/submit-rageshake.js src/ratelimitedfunc.js src/Roles.js src/Rooms.js -src/ScalarAuthClient.js src/UiEffects.js src/Unread.js src/utils/DecryptFile.js diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..afc29f0142 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +patreon: matrixdotorg +liberapay: matrixdotorg diff --git a/.stylelintrc.js b/.stylelintrc.js index fc00b643a0..f028c76cc0 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -1,5 +1,8 @@ module.exports = { "extends": "stylelint-config-standard", + "plugins": [ + "stylelint-scss", + ], "rules": { "indentation": 4, "comment-empty-line-before": null, @@ -11,5 +14,10 @@ module.exports = { "number-no-trailing-zeros": null, "number-leading-zero": null, "selector-list-comma-newline-after": null, + "at-rule-no-unknown": null, + "scss/at-rule-no-unknown": [true, { + // https://github.com/vector-im/riot-web/issues/10544 + "ignoreAtRules": ["define-mixin"], + }], } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 245d0c7e60..3c846612ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,717 @@ +Changes in [1.5.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.5.1) (2019-08-05) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.5.0-rc.1...v1.5.1) + + * Let user know their account has been deactivated upon trying to login + [\#3281](https://github.com/matrix-org/matrix-react-sdk/pull/3281) + +Changes in [1.5.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.5.0) (2019-08-05) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.5.0-rc.1...v1.5.0) + + * Don't load guest sessions on post-registration login link + [\#3277](https://github.com/matrix-org/matrix-react-sdk/pull/3277) + +Changes in [1.5.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.5.0-rc.1) (2019-07-31) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.4.0...v1.5.0-rc.1) + + * Upgrade to JS SDK 2.3.0-rc.1 + * Update from Weblate + [\#3265](https://github.com/matrix-org/matrix-react-sdk/pull/3265) + * Replace React.PropTypes with usage of the `prop-types` package + [\#3263](https://github.com/matrix-org/matrix-react-sdk/pull/3263) + * strikethrough & underline deletions & insertions + [\#3264](https://github.com/matrix-org/matrix-react-sdk/pull/3264) + * Get rid of warning of required prop + [\#3261](https://github.com/matrix-org/matrix-react-sdk/pull/3261) + * Fix html diffs repeating text sometimes + [\#3262](https://github.com/matrix-org/matrix-react-sdk/pull/3262) + * Introduce RoomContext for sharing state between RoomView and children + [\#3260](https://github.com/matrix-org/matrix-react-sdk/pull/3260) + * Upgrade emojibase to fix :anxious: + [\#3259](https://github.com/matrix-org/matrix-react-sdk/pull/3259) + * Add support for IS v2 API with authentication + [\#3256](https://github.com/matrix-org/matrix-react-sdk/pull/3256) + * Fix autocomplete for editing being broken + [\#3258](https://github.com/matrix-org/matrix-react-sdk/pull/3258) + * Unit tests for new editor + [\#3247](https://github.com/matrix-org/matrix-react-sdk/pull/3247) + * Show MessageActionBar buttons conditionally on room state permissions + [\#3255](https://github.com/matrix-org/matrix-react-sdk/pull/3255) + * Handle onPaste AddressPickerDialog, allow addressing CSV/NL/Space delim list + [\#3249](https://github.com/matrix-org/matrix-react-sdk/pull/3249) + * Move history with alt up/down regardless of where selection is + [\#3254](https://github.com/matrix-org/matrix-react-sdk/pull/3254) + * Update from Weblate + [\#3253](https://github.com/matrix-org/matrix-react-sdk/pull/3253) + * Fix /rainbowme and /rainbow breaking apart utf-16 emoji + [\#3248](https://github.com/matrix-org/matrix-react-sdk/pull/3248) + * Tweak interactive tooltip buffer area allow for overshoot + [\#3245](https://github.com/matrix-org/matrix-react-sdk/pull/3245) + * Keep widget URL in permission screen to one line + [\#3243](https://github.com/matrix-org/matrix-react-sdk/pull/3243) + * Avoid visual glitch when terms appear for Integration Manager + [\#3242](https://github.com/matrix-org/matrix-react-sdk/pull/3242) + * Show diff for formatted messages in the edit history + [\#3244](https://github.com/matrix-org/matrix-react-sdk/pull/3244) + * Redirect paste to composer when event target can't receive input + [\#3239](https://github.com/matrix-org/matrix-react-sdk/pull/3239) + * Restore manual composing focusing logic + [\#3241](https://github.com/matrix-org/matrix-react-sdk/pull/3241) + * ToS for ISes/IMs: prompt on use screen + [\#3199](https://github.com/matrix-org/matrix-react-sdk/pull/3199) + * Defer IM token until widget is shown and permission granted + [\#3240](https://github.com/matrix-org/matrix-react-sdk/pull/3240) + * Move read marker past invisible events + [\#3226](https://github.com/matrix-org/matrix-react-sdk/pull/3226) + * Basic diff visualisation for plain text edits + [\#3238](https://github.com/matrix-org/matrix-react-sdk/pull/3238) + * Don't focus composer on keydown with modifier + [\#3237](https://github.com/matrix-org/matrix-react-sdk/pull/3237) + * Focus composer when typing anywhere in the app + [\#3224](https://github.com/matrix-org/matrix-react-sdk/pull/3224) + * Don't show remove button for original event in edit history + [\#3235](https://github.com/matrix-org/matrix-react-sdk/pull/3235) + * Remove feature flags for reactions and edits + [\#3233](https://github.com/matrix-org/matrix-react-sdk/pull/3233) + * Enable reactions and edits by default + [\#3229](https://github.com/matrix-org/matrix-react-sdk/pull/3229) + * Improve interactive tooltip safe mousing area + [\#3228](https://github.com/matrix-org/matrix-react-sdk/pull/3228) + * Add a previous event safe area around action bar + [\#3227](https://github.com/matrix-org/matrix-react-sdk/pull/3227) + * Parse integration manager origins more sensibly + [\#3217](https://github.com/matrix-org/matrix-react-sdk/pull/3217) + * ChatCreateOrReuse show only rooms both you and the other party still in + [\#3225](https://github.com/matrix-org/matrix-react-sdk/pull/3225) + * Check for liveliness on submission when the server was previously dead + [\#3218](https://github.com/matrix-org/matrix-react-sdk/pull/3218) + * Fix autocomplete delay text field not accepting text + [\#3219](https://github.com/matrix-org/matrix-react-sdk/pull/3219) + * Don't show a reason if there's no reason for a kick/ban + [\#3220](https://github.com/matrix-org/matrix-react-sdk/pull/3220) + * Take adjacent invisible events into account for read receipt, even if any + but first should be ignored. + [\#3221](https://github.com/matrix-org/matrix-react-sdk/pull/3221) + * Check content and content.users in power levels + [\#3216](https://github.com/matrix-org/matrix-react-sdk/pull/3216) + * Autojoin rooms when clicking the tombstone + [\#3206](https://github.com/matrix-org/matrix-react-sdk/pull/3206) + * Verify i18n in CI + [\#3209](https://github.com/matrix-org/matrix-react-sdk/pull/3209) + * Send the correct UIA alongside the wrong UIA for backwards comaptibility + [\#3211](https://github.com/matrix-org/matrix-react-sdk/pull/3211) + * Remove unused identityEnabled property from ValidatedServerConfig + [\#3213](https://github.com/matrix-org/matrix-react-sdk/pull/3213) + * Remove misleading text about admins logging people out from soft logout + [\#3205](https://github.com/matrix-org/matrix-react-sdk/pull/3205) + +Changes in [1.4.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.4.0) (2019-07-18) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.4.0-rc.3...v1.4.0) + + * Upgrade to JS SDK 2.2.0 + * Don't show remove button for original event in edit history + [\#3236](https://github.com/matrix-org/matrix-react-sdk/pull/3236) + * Remove feature flags for reactions and edits + [\#3234](https://github.com/matrix-org/matrix-react-sdk/pull/3234) + * Enable reactions and edits by default + [\#3232](https://github.com/matrix-org/matrix-react-sdk/pull/3232) + * Improve interactive tooltip safe mousing area + [\#3231](https://github.com/matrix-org/matrix-react-sdk/pull/3231) + * Add a previous event safe area around action bar + [\#3230](https://github.com/matrix-org/matrix-react-sdk/pull/3230) + +Changes in [1.4.0-rc.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.4.0-rc.3) (2019-07-15) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.4.0-rc.2...v1.4.0-rc.3) + + * Check content and content.users in power levels + [\#3223](https://github.com/matrix-org/matrix-react-sdk/pull/3223) + * Take adjacent invisible events into account for read receipt, even if any + but first should be ignored. + [\#3222](https://github.com/matrix-org/matrix-react-sdk/pull/3222) + +Changes in [1.4.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.4.0-rc.2) (2019-07-12) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.4.0-rc.1...v1.4.0-rc.2) + + * Upgrade to JS SDK 2.2.0-rc.2 to fix regresion in listing devices + * Remove misleading text about admins logging people out from soft logout + [\#3215](https://github.com/matrix-org/matrix-react-sdk/pull/3215) + +Changes in [1.4.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.4.0-rc.1) (2019-07-12) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.3.1...v1.4.0-rc.1) + + * Update from Weblate + [\#3214](https://github.com/matrix-org/matrix-react-sdk/pull/3214) + * Prevent autocomplete on paste, and verserev-ing text before and after : + [\#3210](https://github.com/matrix-org/matrix-react-sdk/pull/3210) + * Close settings after deactivating + [\#3212](https://github.com/matrix-org/matrix-react-sdk/pull/3212) + * Require an issue URL (or notes) on rageshakes + [\#3207](https://github.com/matrix-org/matrix-react-sdk/pull/3207) + * Use r0 media endpoints for group tests + [\#3202](https://github.com/matrix-org/matrix-react-sdk/pull/3202) + * Fix field styling regression + [\#3204](https://github.com/matrix-org/matrix-react-sdk/pull/3204) + * Upgrade dependencies + [\#3203](https://github.com/matrix-org/matrix-react-sdk/pull/3203) + * Show anything other than ban/invite -> leave as a kick + [\#3198](https://github.com/matrix-org/matrix-react-sdk/pull/3198) + * Run stylelint on all SCSS files + [\#3200](https://github.com/matrix-org/matrix-react-sdk/pull/3200) + * Show original event in edit history + [\#3195](https://github.com/matrix-org/matrix-react-sdk/pull/3195) + * Use the state variable for the password when deactivating + [\#3201](https://github.com/matrix-org/matrix-react-sdk/pull/3201) + * Support SSO for rehydrating a soft-logged-out session. + [\#3197](https://github.com/matrix-org/matrix-react-sdk/pull/3197) + * Change highlight colour on dark theme + [\#3196](https://github.com/matrix-org/matrix-react-sdk/pull/3196) + * Dress up the soft logout page to look like the design + [\#3190](https://github.com/matrix-org/matrix-react-sdk/pull/3190) + * Overwrite the old session if the new creds are for a different user + [\#3189](https://github.com/matrix-org/matrix-react-sdk/pull/3189) + * Fix React crash when using a non-default homeserver on soft logout + [\#3188](https://github.com/matrix-org/matrix-react-sdk/pull/3188) + * Change soft logout rehydrate text if there's pending key backups + [\#3187](https://github.com/matrix-org/matrix-react-sdk/pull/3187) + * Ask for the user's password to rehydrate their soft logged out session + [\#3182](https://github.com/matrix-org/matrix-react-sdk/pull/3182) + * Don't try to call bodyToHtml with an empty content + [\#3194](https://github.com/matrix-org/matrix-react-sdk/pull/3194) + * Take server-side aggregation into account for timestamp on (edited) tooltip + [\#3193](https://github.com/matrix-org/matrix-react-sdk/pull/3193) + * Fix some React errors + [\#3164](https://github.com/matrix-org/matrix-react-sdk/pull/3164) + * Preserve reply fallback on edit + [\#3192](https://github.com/matrix-org/matrix-react-sdk/pull/3192) + * Don't show Remove button in ImageView if can't redact, delint ImageView + [\#3191](https://github.com/matrix-org/matrix-react-sdk/pull/3191) + * Edit history actions + [\#3180](https://github.com/matrix-org/matrix-react-sdk/pull/3180) + * Don't allow editing via up-arrow when Replying + [\#3183](https://github.com/matrix-org/matrix-react-sdk/pull/3183) + * If oldContent matches newContent, skip sending the edit + [\#3103](https://github.com/matrix-org/matrix-react-sdk/pull/3103) + * Track live events in timeline and use for read receipts and read markers + [\#3184](https://github.com/matrix-org/matrix-react-sdk/pull/3184) + * Upgrade dependencies + [\#3179](https://github.com/matrix-org/matrix-react-sdk/pull/3179) + * Allow diplayed reaction values to contain anything + [\#3186](https://github.com/matrix-org/matrix-react-sdk/pull/3186) + * Fix interactive tooltip null target error + [\#3185](https://github.com/matrix-org/matrix-react-sdk/pull/3185) + * Require that users go to the soft logout page if they're soft logged out + [\#3181](https://github.com/matrix-org/matrix-react-sdk/pull/3181) + * Emojibase data includes blank variations, accept these when searching + [\#3163](https://github.com/matrix-org/matrix-react-sdk/pull/3163) + * Implement basic soft logout handling + [\#3177](https://github.com/matrix-org/matrix-react-sdk/pull/3177) + * De-lint ScalarAuthClient + [\#3178](https://github.com/matrix-org/matrix-react-sdk/pull/3178) + * show /relations error in edit history dialog + [\#3174](https://github.com/matrix-org/matrix-react-sdk/pull/3174) + +Changes in [1.3.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.3.1) (2019-07-11) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.3.0...v1.3.1) + + * Fix account deactivation + [\#3201](https://github.com/matrix-org/matrix-react-sdk/pull/3201) + * Upgrade lodash dependencies + * Upgrade to JS SDK 2.1.1 + +Changes in [1.3.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.3.0) (2019-07-08) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.3.0-rc.1...v1.3.0) + +No changes since rc.1 + +Changes in [1.3.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.3.0-rc.1) (2019-07-03) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.2.2...v1.3.0-rc.1) + + * MELS handle m.room.third_party_invite + [\#3173](https://github.com/matrix-org/matrix-react-sdk/pull/3173) + * Fix logic around MemberList invites section, specifically regarding 3pid + [\#3172](https://github.com/matrix-org/matrix-react-sdk/pull/3172) + * Update from Weblate + [\#3176](https://github.com/matrix-org/matrix-react-sdk/pull/3176) + * Track the user's own typing state external to the composer + [\#3150](https://github.com/matrix-org/matrix-react-sdk/pull/3150) + * Handle associated event send failures + [\#3170](https://github.com/matrix-org/matrix-react-sdk/pull/3170) + * Improve interactive tooltip hover behaviour + [\#3169](https://github.com/matrix-org/matrix-react-sdk/pull/3169) + * Fix login type selector border + [\#3171](https://github.com/matrix-org/matrix-react-sdk/pull/3171) + * Use the event sender instead of event ID for viaServers off a tombstone + [\#3159](https://github.com/matrix-org/matrix-react-sdk/pull/3159) + * Append keyshare request dialogs instead of replacing the current dialog + [\#3160](https://github.com/matrix-org/matrix-react-sdk/pull/3160) + * Add AccessibleTooltipButton and use it for RoomSubList buttons + [\#3165](https://github.com/matrix-org/matrix-react-sdk/pull/3165) + * MemberInfo wrap Device Name/ID + [\#3166](https://github.com/matrix-org/matrix-react-sdk/pull/3166) + * Correctly populate the dispatch for joining a room via servers + [\#3161](https://github.com/matrix-org/matrix-react-sdk/pull/3161) + * Clean up legacy breadcrumbs persistence fallback + [\#3162](https://github.com/matrix-org/matrix-react-sdk/pull/3162) + * Update from Weblate + [\#3168](https://github.com/matrix-org/matrix-react-sdk/pull/3168) + * Add ability to render null-rejoins in Timeline and MELS + [\#3135](https://github.com/matrix-org/matrix-react-sdk/pull/3135) + * Add /myavatar command + [\#3155](https://github.com/matrix-org/matrix-react-sdk/pull/3155) + * Update config.json docs location + [\#3158](https://github.com/matrix-org/matrix-react-sdk/pull/3158) + * If on trackpad, don't mess with horizontal scrolling. + [\#3148](https://github.com/matrix-org/matrix-react-sdk/pull/3148) + * Limit reactions row on initial display + [\#3152](https://github.com/matrix-org/matrix-react-sdk/pull/3152) + * Unpin highlight.js + [\#3156](https://github.com/matrix-org/matrix-react-sdk/pull/3156) + * Flexboxify generic error page + [\#3154](https://github.com/matrix-org/matrix-react-sdk/pull/3154) + * Fix weird scrollbar when devtools is in a narrow browser + [\#3153](https://github.com/matrix-org/matrix-react-sdk/pull/3153) + * Show a loading state for slow peeks + [\#3142](https://github.com/matrix-org/matrix-react-sdk/pull/3142) + * Don't show error dialog when user has no webcam + [\#3146](https://github.com/matrix-org/matrix-react-sdk/pull/3146) + * Make edit history work in encrypted rooms. + [\#3151](https://github.com/matrix-org/matrix-react-sdk/pull/3151) + * Change interactive tooltip to only flip when required + [\#3147](https://github.com/matrix-org/matrix-react-sdk/pull/3147) + * Edit history dialog + [\#3144](https://github.com/matrix-org/matrix-react-sdk/pull/3144) + * Fix the scrollbar in the community bar + [\#3143](https://github.com/matrix-org/matrix-react-sdk/pull/3143) + * Add focus border to edit composer + [\#3145](https://github.com/matrix-org/matrix-react-sdk/pull/3145) + * Supply oobData to RoomPreviewBar + [\#3141](https://github.com/matrix-org/matrix-react-sdk/pull/3141) + * Don't boost trackpad users in breadcrumbs + [\#3140](https://github.com/matrix-org/matrix-react-sdk/pull/3140) + * Fix room upgrade warning being chopped off and a spelling mistake + [\#3139](https://github.com/matrix-org/matrix-react-sdk/pull/3139) + * Add quick reaction buttons in tooltip + [\#3138](https://github.com/matrix-org/matrix-react-sdk/pull/3138) + * When joining from room directory, use auto_join + [\#3136](https://github.com/matrix-org/matrix-react-sdk/pull/3136) + * Improve API and interactivity of new tooltip + [\#3137](https://github.com/matrix-org/matrix-react-sdk/pull/3137) + * Use feature flag for displaying edits as well + [\#3132](https://github.com/matrix-org/matrix-react-sdk/pull/3132) + * Add interactive tooltip style + [\#3131](https://github.com/matrix-org/matrix-react-sdk/pull/3131) + * Remove redundant extra chevrons from ContextualMenu + [\#3129](https://github.com/matrix-org/matrix-react-sdk/pull/3129) + * Editor caret improvements + [\#3126](https://github.com/matrix-org/matrix-react-sdk/pull/3126) + * Disable left/right arrow navigating completions for now + [\#3130](https://github.com/matrix-org/matrix-react-sdk/pull/3130) + * Take list nesting into account for indenting + [\#3128](https://github.com/matrix-org/matrix-react-sdk/pull/3128) + * Add file size to UploadConfirmDialog + [\#3127](https://github.com/matrix-org/matrix-react-sdk/pull/3127) + * Consider cancelled verifications when mounting IncomingSasDialog + [\#3123](https://github.com/matrix-org/matrix-react-sdk/pull/3123) + * Make the verification cancelled dialog say OK instead of Cancel + [\#3124](https://github.com/matrix-org/matrix-react-sdk/pull/3124) + * Update from Weblate + [\#3125](https://github.com/matrix-org/matrix-react-sdk/pull/3125) + * Remove unused ContextualMenu features + [\#3122](https://github.com/matrix-org/matrix-react-sdk/pull/3122) + * Fix casing of TooltipButton + [\#3119](https://github.com/matrix-org/matrix-react-sdk/pull/3119) + * De-duplicate notif badge code + [\#3120](https://github.com/matrix-org/matrix-react-sdk/pull/3120) + * Fix favicon/title badge count + [\#3121](https://github.com/matrix-org/matrix-react-sdk/pull/3121) + * Switch ugly password boxes to Field or styled input + [\#3071](https://github.com/matrix-org/matrix-react-sdk/pull/3071) + * Restore warning for if you're already logged in + [\#3118](https://github.com/matrix-org/matrix-react-sdk/pull/3118) + * Provide default name if device label is missing + [\#3113](https://github.com/matrix-org/matrix-react-sdk/pull/3113) + * Support @room pills while editing + [\#3108](https://github.com/matrix-org/matrix-react-sdk/pull/3108) + +Changes in [1.2.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.2.2) (2019-06-19) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.2.2-rc.2...v1.2.2) + +No changes since rc.2 + +Changes in [1.2.2-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.2.2-rc.2) (2019-06-18) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.2.2-rc.1...v1.2.2-rc.2) + + * Defer scalar API calls until they are needed + [\#3115](https://github.com/matrix-org/matrix-react-sdk/pull/3115) + * Blend pending redactions + [\#3117](https://github.com/matrix-org/matrix-react-sdk/pull/3117) + * Keep old arrow-up behaviour when editing is not enabled + [\#3116](https://github.com/matrix-org/matrix-react-sdk/pull/3116) + * Restore Composer History under shift-up & down + [\#3098](https://github.com/matrix-org/matrix-react-sdk/pull/3098) + * Allow changing server if validation has failed + [\#3114](https://github.com/matrix-org/matrix-react-sdk/pull/3114) + * Add Upload All button to UploadConfirmDialog + [\#3109](https://github.com/matrix-org/matrix-react-sdk/pull/3109) + * Re-enable register button + [\#3112](https://github.com/matrix-org/matrix-react-sdk/pull/3112) + * keep mx_Field stretching + [\#3111](https://github.com/matrix-org/matrix-react-sdk/pull/3111) + * Fix double-spinner + [\#3107](https://github.com/matrix-org/matrix-react-sdk/pull/3107) + * Fix display of canonicalAlias in group room info + [\#3110](https://github.com/matrix-org/matrix-react-sdk/pull/3110) + * Fix welcome user + [\#3106](https://github.com/matrix-org/matrix-react-sdk/pull/3106) + * Support editing emote messages + [\#3105](https://github.com/matrix-org/matrix-react-sdk/pull/3105) + * Use flex: 1 for mx_Field to replace all the calc(100% - 20px) and more + [\#3104](https://github.com/matrix-org/matrix-react-sdk/pull/3104) + * Use overflow on MemberInfo name/mxid so that the back button stays + [\#3099](https://github.com/matrix-org/matrix-react-sdk/pull/3099) + * Allow changing servers on nonfatal errors + [\#3102](https://github.com/matrix-org/matrix-react-sdk/pull/3102) + * Simplify email registration + [\#3101](https://github.com/matrix-org/matrix-react-sdk/pull/3101) + * Allow arrow keys navigation in autocomplete list + [\#2966](https://github.com/matrix-org/matrix-react-sdk/pull/2966) + * Edit unsent messages + [\#3097](https://github.com/matrix-org/matrix-react-sdk/pull/3097) + * Fix registration with email + non-default HS + [\#3096](https://github.com/matrix-org/matrix-react-sdk/pull/3096) + * Raise action bar above read marker + [\#3095](https://github.com/matrix-org/matrix-react-sdk/pull/3095) + * Console log more helpfully + [\#3094](https://github.com/matrix-org/matrix-react-sdk/pull/3094) + +Changes in [1.2.2-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.2.2-rc.1) (2019-06-12) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.2.1...v1.2.2-rc.1) + + * Align message context menu to right and vertically where space available + [\#3087](https://github.com/matrix-org/matrix-react-sdk/pull/3087) + * Allow registration to submit for non-fatal errors + [\#3093](https://github.com/matrix-org/matrix-react-sdk/pull/3093) + * Clear the login busy state after .well-known discovery + [\#3092](https://github.com/matrix-org/matrix-react-sdk/pull/3092) + * Update from Weblate + [\#3091](https://github.com/matrix-org/matrix-react-sdk/pull/3091) + * Fix registration after fail-fast + [\#3090](https://github.com/matrix-org/matrix-react-sdk/pull/3090) + * Use setBusy interface of js-sdk interactive auth + [\#3085](https://github.com/matrix-org/matrix-react-sdk/pull/3085) + * Don't handle identity server failure as fatal, and use the right message + [\#3088](https://github.com/matrix-org/matrix-react-sdk/pull/3088) + * Recheck message actions on decrypt + [\#3084](https://github.com/matrix-org/matrix-react-sdk/pull/3084) + * Fix exception on logout + [\#3086](https://github.com/matrix-org/matrix-react-sdk/pull/3086) + * Remember we were trying to accept an invite + [\#3083](https://github.com/matrix-org/matrix-react-sdk/pull/3083) + * Add funding details for GitHub sponsor button + [\#3079](https://github.com/matrix-org/matrix-react-sdk/pull/3079) + * Remove highlight from reactions + [\#3081](https://github.com/matrix-org/matrix-react-sdk/pull/3081) + * Clarify that only lowercase letters are allowed + [\#3080](https://github.com/matrix-org/matrix-react-sdk/pull/3080) + * Don't handle identity server liveliness errors as fatal + [\#3082](https://github.com/matrix-org/matrix-react-sdk/pull/3082) + * truncate long display names in timeline headings + [\#3078](https://github.com/matrix-org/matrix-react-sdk/pull/3078) + * Fail more softly on homeserver liveliness errors + [\#3067](https://github.com/matrix-org/matrix-react-sdk/pull/3067) + * Fix AddressPickerDialog adding wrong entry to selected list case + [\#3076](https://github.com/matrix-org/matrix-react-sdk/pull/3076) + * change profile keybind to backtick from i due to italics conflict + [\#3077](https://github.com/matrix-org/matrix-react-sdk/pull/3077) + * Look busy whilst requesting the email token + [\#3075](https://github.com/matrix-org/matrix-react-sdk/pull/3075) + * Fix email invites address-match checking + [\#3074](https://github.com/matrix-org/matrix-react-sdk/pull/3074) + * Add license info for Twemoji + [\#3073](https://github.com/matrix-org/matrix-react-sdk/pull/3073) + * Show read receipts on top of message + [\#3072](https://github.com/matrix-org/matrix-react-sdk/pull/3072) + * Be somewhat fuzzier when matching emojis to complete on space + [\#3070](https://github.com/matrix-org/matrix-react-sdk/pull/3070) + * Restrict reactions to a single emoji + [\#3069](https://github.com/matrix-org/matrix-react-sdk/pull/3069) + * Fix live updates to reaction row buttons + [\#3068](https://github.com/matrix-org/matrix-react-sdk/pull/3068) + * Don't refresh custom status on logout + [\#3065](https://github.com/matrix-org/matrix-react-sdk/pull/3065) + * Add a logged in class to EmbeddedPage and react to MatrixClient changes + [\#3066](https://github.com/matrix-org/matrix-react-sdk/pull/3066) + * Don't show "can't redact" dialog on network error, with redaction having + local echo & queuing now. + [\#3058](https://github.com/matrix-org/matrix-react-sdk/pull/3058) + * Fix login page breaking on wrong password + [\#3062](https://github.com/matrix-org/matrix-react-sdk/pull/3062) + * Update from Weblate + [\#3064](https://github.com/matrix-org/matrix-react-sdk/pull/3064) + * Install latest JS SDK when linting + [\#3063](https://github.com/matrix-org/matrix-react-sdk/pull/3063) + * Ensure we always show read receipts even with hidden events + [\#3056](https://github.com/matrix-org/matrix-react-sdk/pull/3056) + * Advance read receipts into trailing events without tiles + [\#3059](https://github.com/matrix-org/matrix-react-sdk/pull/3059) + * Remove unused errorText prop + [\#3061](https://github.com/matrix-org/matrix-react-sdk/pull/3061) + * Remove SettingsStore reference in RoomSettingsDialog + [\#3060](https://github.com/matrix-org/matrix-react-sdk/pull/3060) + * Custom notification sounds for rooms + [\#2928](https://github.com/matrix-org/matrix-react-sdk/pull/2928) + * Fix comments in unread room tracking + [\#3054](https://github.com/matrix-org/matrix-react-sdk/pull/3054) + * Allow source tile handler for replacements + [\#3057](https://github.com/matrix-org/matrix-react-sdk/pull/3057) + * Fix linting in MessagePanel + [\#3055](https://github.com/matrix-org/matrix-react-sdk/pull/3055) + * Convert breadcrumbs from labs to real setting + [\#3053](https://github.com/matrix-org/matrix-react-sdk/pull/3053) + * Add local echo on badges in breadcrumbs + [\#3052](https://github.com/matrix-org/matrix-react-sdk/pull/3052) + * Counteract smooth scrolling on breadcrumbs + [\#3051](https://github.com/matrix-org/matrix-react-sdk/pull/3051) + * add sbix fallback twemoji font (and bump to emoji 12) + [\#3050](https://github.com/matrix-org/matrix-react-sdk/pull/3050) + * Add option to change the default country code + [\#3049](https://github.com/matrix-org/matrix-react-sdk/pull/3049) + * Accept JSX into the GenericErrorPage and expose local session vars + [\#3043](https://github.com/matrix-org/matrix-react-sdk/pull/3043) + * Don't try and low encryption info when signing out in low bandwidth mode + [\#3048](https://github.com/matrix-org/matrix-react-sdk/pull/3048) + * only capture enter if something was selected in completions + [\#3047](https://github.com/matrix-org/matrix-react-sdk/pull/3047) + * Fix: better HTML > MD conversion for editing, including lists and quotes + [\#3040](https://github.com/matrix-org/matrix-react-sdk/pull/3040) + * Native emoji require extra line-height + [\#3044](https://github.com/matrix-org/matrix-react-sdk/pull/3044) + * port over low_bandwidth mode to develop + [\#2598](https://github.com/matrix-org/matrix-react-sdk/pull/2598) + * Fix: maintain caret at current line when position is on newline part + [\#3029](https://github.com/matrix-org/matrix-react-sdk/pull/3029) + * Remove username on HS input label + [\#3042](https://github.com/matrix-org/matrix-react-sdk/pull/3042) + * Exclude chrome in ua from safari version check for colr support + [\#3038](https://github.com/matrix-org/matrix-react-sdk/pull/3038) + * fix COLR font check being racy + [\#3034](https://github.com/matrix-org/matrix-react-sdk/pull/3034) + * Override font for usercontent download link + [\#3035](https://github.com/matrix-org/matrix-react-sdk/pull/3035) + * Revert "Make the timeline less noisy for screen readers (mk II) #3019" + [\#3033](https://github.com/matrix-org/matrix-react-sdk/pull/3033) + * Hide autocomplete on Enter key press instead of sending message + [\#2968](https://github.com/matrix-org/matrix-react-sdk/pull/2968) + * Message editing: arrow key (up/down) navigation between editable events + [\#3025](https://github.com/matrix-org/matrix-react-sdk/pull/3025) + * Message editing: fix reply text appearing in edit + [\#3032](https://github.com/matrix-org/matrix-react-sdk/pull/3032) + * Do not try to request thumbnails with non-integer widths + [\#3031](https://github.com/matrix-org/matrix-react-sdk/pull/3031) + * Message editing: preserve strikethrough as well + [\#3030](https://github.com/matrix-org/matrix-react-sdk/pull/3030) + * Add some logging for COLR checks + [\#3027](https://github.com/matrix-org/matrix-react-sdk/pull/3027) + * Fixup for tab completion: take part length into account as well + [\#3026](https://github.com/matrix-org/matrix-react-sdk/pull/3026) + * Message editing: tab completion + [\#3024](https://github.com/matrix-org/matrix-react-sdk/pull/3024) + * Message editing: dont jump to next part when inserting at *start* of + uneditable part + [\#3021](https://github.com/matrix-org/matrix-react-sdk/pull/3021) + * Message editing: preserve and re-apply formatting + [\#3013](https://github.com/matrix-org/matrix-react-sdk/pull/3013) + * Fix relationship between guests, .well-known, and auth + [\#3001](https://github.com/matrix-org/matrix-react-sdk/pull/3001) + * Restore use of full mxid login + [\#2972](https://github.com/matrix-org/matrix-react-sdk/pull/2972) + * Only expose the fallback_hs_url if the homeserver is the default homeserver + [\#2971](https://github.com/matrix-org/matrix-react-sdk/pull/2971) + * Refactor "Next" button into ServerConfig components + [\#2964](https://github.com/matrix-org/matrix-react-sdk/pull/2964) + * Render underlines and tooltips on custom server names in auth pages + [\#2965](https://github.com/matrix-org/matrix-react-sdk/pull/2965) + * Use validated server config for login, registration, and password reset + [\#2941](https://github.com/matrix-org/matrix-react-sdk/pull/2941) + +Changes in [1.2.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.2.1) (2019-05-31) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.2.0...v1.2.1) + + * Upgrade to JS SDK 2.0.0 which fixes an error during key backup + * Native emoji require extra line-height for release + [\#3045](https://github.com/matrix-org/matrix-react-sdk/pull/3045) + +Changes in [1.2.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.2.0) (2019-05-29) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.2.0-rc.1...v1.2.0) + + * COLR font check fixes for release + [\#3041](https://github.com/matrix-org/matrix-react-sdk/pull/3041) + * Revert "Make the timeline less noisy for screen readers (mk II) #3019" for + release + [\#3036](https://github.com/matrix-org/matrix-react-sdk/pull/3036) + * Override font for usercontent download link for release + [\#3037](https://github.com/matrix-org/matrix-react-sdk/pull/3037) + +Changes in [1.2.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.2.0-rc.1) (2019-05-23) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.1.2...v1.2.0-rc.1) + + * Update from Weblate + [\#3023](https://github.com/matrix-org/matrix-react-sdk/pull/3023) + * Use the correct line-height for bold emoji + [\#3022](https://github.com/matrix-org/matrix-react-sdk/pull/3022) + * Make the timeline less noisy for screen readers (mk II) + [\#3019](https://github.com/matrix-org/matrix-react-sdk/pull/3019) + * Label message edit field as such for screen readers + [\#3020](https://github.com/matrix-org/matrix-react-sdk/pull/3020) + * Move checkmark to the front of key backup message + [\#3014](https://github.com/matrix-org/matrix-react-sdk/pull/3014) + * Revert "Make the timeline less noisy for screen readers" + [\#3017](https://github.com/matrix-org/matrix-react-sdk/pull/3017) + * Translate scroll movement if the deltaX is the same as the threshold + [\#3016](https://github.com/matrix-org/matrix-react-sdk/pull/3016) + * Make the timeline less noisy for screen readers + [\#3007](https://github.com/matrix-org/matrix-react-sdk/pull/3007) + * Windows emoji tweaks + [\#3015](https://github.com/matrix-org/matrix-react-sdk/pull/3015) + * Message editing: update link previews after editing + [\#3004](https://github.com/matrix-org/matrix-react-sdk/pull/3004) + * js-sdk interactive auth now sends email token + [\#3010](https://github.com/matrix-org/matrix-react-sdk/pull/3010) + * remove SBIX font and fallback to native emoji + [\#3011](https://github.com/matrix-org/matrix-react-sdk/pull/3011) + * Update from Weblate + [\#3012](https://github.com/matrix-org/matrix-react-sdk/pull/3012) + * load twemoji dynamically as colr or sbix; fix monospace + [\#3008](https://github.com/matrix-org/matrix-react-sdk/pull/3008) + * Guard against null rooms in `onEventDecrypted` + [\#3009](https://github.com/matrix-org/matrix-react-sdk/pull/3009) + * Only show reactions in main message timeline + [\#3005](https://github.com/matrix-org/matrix-react-sdk/pull/3005) + * Add voice labels for quick add room buttons + [\#3006](https://github.com/matrix-org/matrix-react-sdk/pull/3006) + * Update TopLeftMenu for accessibility: Keyboard shortcut, reduced screen + reader noise + [\#2994](https://github.com/matrix-org/matrix-react-sdk/pull/2994) + * Remove reacted with text when shortcode missing + [\#3003](https://github.com/matrix-org/matrix-react-sdk/pull/3003) + * Fixup: also change editor margin when last event and buttons are not + overlaying + [\#3002](https://github.com/matrix-org/matrix-react-sdk/pull/3002) + * Message editing: render avatars for pills in the editor + [\#2997](https://github.com/matrix-org/matrix-react-sdk/pull/2997) + * Replace emojione with twemoji + emojibase + [\#2995](https://github.com/matrix-org/matrix-react-sdk/pull/2995) + * Hide WhoIsTyping component if the MessagePanel is shaped e.g file grid + [\#3000](https://github.com/matrix-org/matrix-react-sdk/pull/3000) + * Close copy tooltip in edge cases correctly + [\#2999](https://github.com/matrix-org/matrix-react-sdk/pull/2999) + * Limit reaction sender tooltip to 6 people + [\#2998](https://github.com/matrix-org/matrix-react-sdk/pull/2998) + * Message editing: apply design + [\#2996](https://github.com/matrix-org/matrix-react-sdk/pull/2996) + * Add debug feature to show hidden events in timeline + [\#2993](https://github.com/matrix-org/matrix-react-sdk/pull/2993) + * Mute screen readers over reactions + [\#2986](https://github.com/matrix-org/matrix-react-sdk/pull/2986) + * Fix not being able to edit already edited messages + [\#2992](https://github.com/matrix-org/matrix-react-sdk/pull/2992) + * Add a basic tooltip showing who reacted + [\#2991](https://github.com/matrix-org/matrix-react-sdk/pull/2991) + * Message editing: show (edited) marker on edited messages, with tooltip + [\#2990](https://github.com/matrix-org/matrix-react-sdk/pull/2990) + * Update from Weblate + [\#2989](https://github.com/matrix-org/matrix-react-sdk/pull/2989) + * Message editing: only allow editing of text messages + [\#2988](https://github.com/matrix-org/matrix-react-sdk/pull/2988) + * Message editing: shift+enter for newline, enter to send + [\#2987](https://github.com/matrix-org/matrix-react-sdk/pull/2987) + * Apply Flex voodoo for devtools send event dialog + [\#2985](https://github.com/matrix-org/matrix-react-sdk/pull/2985) + * Fix some source strings noticed as incorrect by translators + [\#2984](https://github.com/matrix-org/matrix-react-sdk/pull/2984) + * Message editing: fix some bugs in cursor behaviour + [\#2983](https://github.com/matrix-org/matrix-react-sdk/pull/2983) + * Message editing: local echo & back-pagination + [\#2982](https://github.com/matrix-org/matrix-react-sdk/pull/2982) + * Listen for removed relations + [\#2981](https://github.com/matrix-org/matrix-react-sdk/pull/2981) + * Update from Weblate + [\#2980](https://github.com/matrix-org/matrix-react-sdk/pull/2980) + * Use `getRelation` helper + [\#2977](https://github.com/matrix-org/matrix-react-sdk/pull/2977) + * Add tooltips to rotate and close buttons in ImageView (#9686) + [\#2979](https://github.com/matrix-org/matrix-react-sdk/pull/2979) + * Message editing: smaller fixes + [\#2978](https://github.com/matrix-org/matrix-react-sdk/pull/2978) + * Message editing: adjust to js-sdk changes of marking original event as + replaced + [\#2973](https://github.com/matrix-org/matrix-react-sdk/pull/2973) + * Fix Single Sign-on + [\#2974](https://github.com/matrix-org/matrix-react-sdk/pull/2974) + * Initial support for editing messages + [\#2952](https://github.com/matrix-org/matrix-react-sdk/pull/2952) + * Check permission to invite before showing invite buttons/disable them + [\#2957](https://github.com/matrix-org/matrix-react-sdk/pull/2957) + * Support a backup room ID in PermalinkCreator + [\#2963](https://github.com/matrix-org/matrix-react-sdk/pull/2963) + * Always thumbnail for GIFs + [\#2962](https://github.com/matrix-org/matrix-react-sdk/pull/2962) + * Fix registration with email + [\#2967](https://github.com/matrix-org/matrix-react-sdk/pull/2967) + * Add configuration flag to disable minimum password requirements + [\#2947](https://github.com/matrix-org/matrix-react-sdk/pull/2947) + * Send and undo reaction events + [\#2954](https://github.com/matrix-org/matrix-react-sdk/pull/2954) + * Fix bug where email was not required where it shouldn't have been + [\#2961](https://github.com/matrix-org/matrix-react-sdk/pull/2961) + * add /rainbow and /rainbowme Slash Commands + [\#2958](https://github.com/matrix-org/matrix-react-sdk/pull/2958) + * Fix invite via MemberInfo + [\#2956](https://github.com/matrix-org/matrix-react-sdk/pull/2956) + * Close Room Settings upon Leave Room + [\#2955](https://github.com/matrix-org/matrix-react-sdk/pull/2955) + * Command to change avatar for a single room, including upload of mxc res + [\#2953](https://github.com/matrix-org/matrix-react-sdk/pull/2953) + * Add View Servers in Room to Devtools + [\#2804](https://github.com/matrix-org/matrix-react-sdk/pull/2804) + * Update 'Rooms' import RoomView.js file + [\#2951](https://github.com/matrix-org/matrix-react-sdk/pull/2951) + * Extract `ReactionDimension` out of `MessageActionBar` + [\#2950](https://github.com/matrix-org/matrix-react-sdk/pull/2950) + * Always default to the registration form + [\#2942](https://github.com/matrix-org/matrix-react-sdk/pull/2942) + * Check for `room` in all `Room.timeline*` handlers + [\#2945](https://github.com/matrix-org/matrix-react-sdk/pull/2945) + * Remove the karma junit reporter + [\#2944](https://github.com/matrix-org/matrix-react-sdk/pull/2944) + * yarn upgrade + [\#2943](https://github.com/matrix-org/matrix-react-sdk/pull/2943) + * Support changing options for .m.rule.tombstone push rule + [\#2798](https://github.com/matrix-org/matrix-react-sdk/pull/2798) + * Remove timeline explosion rageshake prompt + [\#2939](https://github.com/matrix-org/matrix-react-sdk/pull/2939) + * Add existing reactions below message + [\#2940](https://github.com/matrix-org/matrix-react-sdk/pull/2940) + * Fix lint errors in TimelinePanel + [\#2938](https://github.com/matrix-org/matrix-react-sdk/pull/2938) + * Add primary reactions to action bar + [\#2937](https://github.com/matrix-org/matrix-react-sdk/pull/2937) + Changes in [1.1.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.1.2) (2019-05-15) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.1.1...v1.1.2) diff --git a/docs/ciderEditor.md b/docs/ciderEditor.md new file mode 100644 index 0000000000..e67c74a95c --- /dev/null +++ b/docs/ciderEditor.md @@ -0,0 +1,72 @@ +# The CIDER (Contenteditable-Input-Diff-Error-Reconcile) editor + +The CIDER editor is a custom editor written for Riot. +Most of the code can be found in the `/editor/` directory of the `matrix-react-sdk` project. +It is used to power the composer to edit messages, +and will soon be used as the main composer to send messages as well. + +## High-level overview. + +The editor is backed by a model that contains parts. +A part has some text and a type (plain text, pill, ...). When typing in the editor, +the model validates the input and updates the parts. +The parts are then reconciled with the DOM. + +## Inner workings + +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 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. +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 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 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. + +For example, if you type an @ in some plain text, the plain text part rejects +that character, and this character is then presented to the part creator, +which will turn it into a pill candidate part. +Pill candidate parts are what opens the auto completion, and upon picking a completion, +replace themselves with an actual pill which can't be edited anymore. + +The diffing is needed to preserve state in the parts apart from their text +(which is the only thing the model receives from the DOM), e.g. to build +the model incrementally. Any text that didn't change is assumed +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, 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. +If the model didn't reject the input and didn't make any additional changes, +this won't make any changes to the DOM at all, and should thus be fairly efficient. + +For the browser to allow the user to place the caret between two pills, +or between a pill and the start and end of the line, we need some extra DOM nodes. +These DOM nodes are called caret nodes, and contain an invisble character, so +the caret can be placed into them. The model is unaware of caret nodes, and they +are only added to the DOM during the render phase. Likewise, when calculating +the content string, caret nodes need to be ignored, as they would confuse the model. + +As part of the reconciliation, the caret position is also adjusted to any changes +the model made to the input. The caret is passed around in two formats. +The model receives the caret *offset* within the content string (which includes +an atNodeEnd flag to make it unambiguous if it is at a part and or the next part start). +The model converts this to a caret *position* internally, which has a partIndex +and an offset within the part text, which is more natural to work with. +From there on, the caret *position* is used, also during reconciliation. 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 ac08372843..b4e1af9f0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "1.1.2", + "version": "1.5.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -40,6 +40,7 @@ "rethemendex": "res/css/rethemendex.sh", "i18n": "matrix-gen-i18n", "prunei18n": "matrix-prune-i18n", + "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", "build": "yarn reskindex && yarn start:init", "build:watch": "babel src -w --skip-initial-build -d lib --source-maps --copy-files", "emoji-data-strip": "node scripts/emoji-data-strip.js", @@ -49,7 +50,7 @@ "lint": "eslint src/", "lintall": "eslint src/ test/", "lintwithexclusions": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test", - "stylelint": "stylelint res/css/**/*.scss", + "stylelint": "stylelint 'res/css/**/*.scss'", "clean": "rimraf lib", "prepare": "yarn clean && yarn build && git rev-parse HEAD > git-revision.txt", "test": "karma start --single-run=true --browsers VectorChromeHeadless", @@ -65,23 +66,26 @@ "classnames": "^2.1.2", "commonmark": "^0.28.1", "counterpart": "^0.18.0", - "emojibase-data": "^4.0.0", + "create-react-class": "^15.6.0", + "diff-dom": "^4.1.3", + "diff-match-patch": "^1.0.4", + "emojibase-data": "^4.0.1", "emojibase-regex": "^3.0.0", "file-saver": "^1.3.3", "filesize": "3.5.6", "flux": "2.1.1", "focus-trap-react": "^3.0.5", "fuse.js": "^2.2.0", - "gemini-scrollbar": "github:matrix-org/gemini-scrollbar#b302279", + "gemini-scrollbar": "github:matrix-org/gemini-scrollbar#91e1e566", "gfm.css": "^1.1.1", "glob": "^5.0.14", - "highlight.js": "9.14.2", + "highlight.js": "^9.15.8", "is-ip": "^2.0.0", "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.6", - "lodash": "^4.13.1", + "lodash": "^4.17.14", "lolex": "2.3.2", - "matrix-js-sdk": "1.1.0", + "matrix-js-sdk": "2.3.0", "optimist": "^0.6.1", "pako": "^1.0.5", "png-chunks-extract": "^1.0.0", @@ -93,7 +97,7 @@ "react-addons-css-transition-group": "15.3.2", "react-beautiful-dnd": "^4.0.1", "react-dom": "^15.6.0", - "react-gemini-scrollbar": "github:matrix-org/react-gemini-scrollbar#5e97aef", + "react-gemini-scrollbar": "github:matrix-org/react-gemini-scrollbar#f644523", "resize-observer-polyfill": "^1.5.0", "sanitize-html": "^1.18.4", "slate": "^0.41.2", @@ -144,15 +148,15 @@ "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", "source-map-loader": "^0.2.3", "stylelint": "^9.10.1", "stylelint-config-standard": "^18.2.0", + "stylelint-scss": "^3.9.0", "walk": "^2.3.9", "webpack": "^4.20.2", "webpack-cli": "^3.1.1" diff --git a/res/css/_common.scss b/res/css/_common.scss index 973103899d..859c0006a1 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -104,17 +104,17 @@ input[type=text], input[type=password], textarea { color: $primary-fg-color; } -input[type=text]:focus, input[type=password]:focus, textarea:focus { - outline: none; - box-shadow: none; -} - /* Required by Firefox */ textarea { font-family: $font-family; color: $primary-fg-color; } +input[type=text]:focus, input[type=password]:focus, textarea:focus { + outline: none; + box-shadow: none; +} + // This is used to hide the standard outline added by browsers for // accessible (focusable) components. Not intended for buttons, but // should be used on things like focusable containers where the outline @@ -129,6 +129,13 @@ textarea { // appear to be part of the input .mx_Dialog, .mx_MatrixChat { + .mx_textinput > input[type=text], + .mx_textinput > input[type=search] { + border: none; + flex: 1; + color: $primary-fg-color; + } + :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type=text], :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type=search], .mx_textinput { @@ -147,13 +154,6 @@ textarea { .mx_textinput { display: flex; align-items: center; - - > input[type=text], - > input[type=search] { - border: none; - flex: 1; - color: $primary-fg-color; - } } :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type=text]::placeholder, @@ -166,9 +166,7 @@ textarea { /*** panels ***/ .dark-panel { background-color: $dark-panel-bg-color; -} -.dark-panel { :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type=text], :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type=search], .mx_textinput { @@ -209,25 +207,25 @@ textarea { /* Expand thumbs on hoverover */ .gm-scrollbar { - border-radius: 5px ! important; + border-radius: 5px !important; } .gm-scrollbar.-vertical { width: 6px; - transition: width 120ms ease-out ! important; + transition: width 120ms ease-out !important; } .gm-scrollbar.-vertical:hover, .gm-scrollbar.-vertical:active { width: 8px; - transition: width 120ms ease-out ! important; + transition: width 120ms ease-out !important; } .gm-scrollbar.-horizontal { height: 6px; - transition: height 120ms ease-out ! important; + transition: height 120ms ease-out !important; } .gm-scrollbar.-horizontal:hover, .gm-scrollbar.-horizontal:active { height: 8px; - transition: height 120ms ease-out ! important; + transition: height 120ms ease-out !important; } // These are magic constants which are excluded from tinting, to let themes @@ -271,14 +269,6 @@ textarea { justify-content: center; } -/* Spinner Dialog overide */ -.mx_Dialog_wrapper.mx_Dialog_spinner .mx_Dialog { - width: auto; - border-radius: 8px; - padding: 0px; - box-shadow: none; -} - .mx_Dialog { background-color: $primary-bg-color; color: $light-fg-color; @@ -390,7 +380,7 @@ textarea { // flip colours for the secondary ones font-weight: 600; - border: 1px solid $accent-color ! important; + border: 1px solid $accent-color; color: $accent-color; background-color: $button-secondary-bg-color; } @@ -407,7 +397,7 @@ textarea { filter: brightness($focus-brightness); } -.mx_Dialog button.mx_Dialog_primary, .mx_Dialog input[type="submit"].mx_Dialog_primary { +.mx_Dialog button.mx_Dialog_primary, .mx_Dialog input[type="submit"].mx_Dialog_primary { color: $accent-fg-color; background-color: $accent-color; min-width: 156px; @@ -425,6 +415,14 @@ textarea { opacity: 0.7; } +/* Spinner Dialog overide */ +.mx_Dialog_wrapper.mx_Dialog_spinner .mx_Dialog { + width: auto; + border-radius: 8px; + padding: 0px; + box-shadow: none; +} + // TODO: Review mx_GeneralButton usage to see if it can use a different class // These classes were brought in from the old UserSettings and are included here to avoid // breaking the app. @@ -561,3 +559,12 @@ textarea { .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 4b8b687146..fb6058df00 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -50,7 +50,6 @@ @import "./views/context_menus/_TopLeftMenu.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; -@import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @import "./views/dialogs/_ConfirmUserActionDialog.scss"; @@ -62,6 +61,7 @@ @import "./views/dialogs/_EncryptedEventDialog.scss"; @import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; +@import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_RestoreKeyBackupDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomUpgradeDialog.scss"; @@ -70,6 +70,9 @@ @import "./views/dialogs/_SetPasswordDialog.scss"; @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"; @import "./views/dialogs/_UserSettingsDialog.scss"; @@ -87,9 +90,9 @@ @import "./views/elements/_Field.scss"; @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InlineSpinner.scss"; +@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"; @@ -98,9 +101,10 @@ @import "./views/elements/_RoleButton.scss"; @import "./views/elements/_Spinner.scss"; @import "./views/elements/_SyntaxHighlight.scss"; +@import "./views/elements/_TextWithTooltip.scss"; @import "./views/elements/_ToggleSwitch.scss"; -@import "./views/elements/_ToolTipButton.scss"; @import "./views/elements/_Tooltip.scss"; +@import "./views/elements/_TooltipButton.scss"; @import "./views/elements/_Validation.scss"; @import "./views/globals/_MatrixToolbar.scss"; @import "./views/groups/_GroupPublicityToggle.scss"; @@ -116,7 +120,8 @@ @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; -@import "./views/messages/_ReactionDimension.scss"; +@import "./views/messages/_ReactionQuickTooltip.scss"; +@import "./views/messages/_ReactionTooltipButton.scss"; @import "./views/messages/_ReactionsRow.scss"; @import "./views/messages/_ReactionsRowButton.scss"; @import "./views/messages/_ReactionsRowButtonTooltip.scss"; @@ -130,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"; @@ -153,6 +160,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"; @@ -163,6 +171,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"; @@ -173,6 +183,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/_ContextualMenu.scss b/res/css/structures/_ContextualMenu.scss index fc1538a13d..fa2d87029d 100644 --- a/res/css/structures/_ContextualMenu.scss +++ b/res/css/structures/_ContextualMenu.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. @@ -39,7 +40,11 @@ limitations under the License. z-index: 5001; } -.mx_ContextualMenu.mx_ContextualMenu_right { +.mx_ContextualMenu_right { + right: 0; +} + +.mx_ContextualMenu.mx_ContextualMenu_withChevron_right { right: 8px; } @@ -54,19 +59,11 @@ limitations under the License. border-bottom: 8px solid transparent; } -.mx_ContextualMenu_chevron_right::after { - content: ''; - width: 0; - height: 0; - border-top: 7px solid transparent; - border-left: 7px solid $menu-bg-color; - border-bottom: 7px solid transparent; - position: absolute; - top: -7px; - right: 1px; +.mx_ContextualMenu_left { + left: 0; } -.mx_ContextualMenu.mx_ContextualMenu_left { +.mx_ContextualMenu.mx_ContextualMenu_withChevron_left { left: 8px; } @@ -81,19 +78,11 @@ limitations under the License. border-bottom: 8px solid transparent; } -.mx_ContextualMenu_chevron_left::after { - content: ''; - width: 0; - height: 0; - border-top: 7px solid transparent; - border-right: 7px solid $menu-bg-color; - border-bottom: 7px solid transparent; - position: absolute; - top: -7px; - left: 1px; +.mx_ContextualMenu_top { + top: 0; } -.mx_ContextualMenu.mx_ContextualMenu_top { +.mx_ContextualMenu.mx_ContextualMenu_withChevron_top { top: 8px; } @@ -108,19 +97,11 @@ limitations under the License. border-right: 8px solid transparent; } -.mx_ContextualMenu_chevron_top::after { - content: ''; - width: 0; - height: 0; - border-left: 7px solid transparent; - border-bottom: 7px solid $menu-bg-color; - border-right: 7px solid transparent; - position: absolute; - left: -7px; - top: 1px; +.mx_ContextualMenu_bottom { + bottom: 0; } -.mx_ContextualMenu.mx_ContextualMenu_bottom { +.mx_ContextualMenu.mx_ContextualMenu_withChevron_bottom { bottom: 8px; } @@ -135,24 +116,6 @@ limitations under the License. border-right: 8px solid transparent; } -.mx_ContextualMenu_chevron_bottom::after { - content: ''; - width: 0; - height: 0; - border-left: 7px solid transparent; - border-top: 7px solid $menu-bg-color; - border-right: 7px solid transparent; - position: absolute; - left: -7px; - bottom: 1px; -} - -.mx_ContextualMenu_field { - padding: 3px 6px 3px 6px; - cursor: pointer; - white-space: nowrap; -} - .mx_ContextualMenu_spinner { display: block; margin: 0 auto; diff --git a/res/css/structures/_GenericErrorPage.scss b/res/css/structures/_GenericErrorPage.scss index 9c973af411..7e9d7bbdaa 100644 --- a/res/css/structures/_GenericErrorPage.scss +++ b/res/css/structures/_GenericErrorPage.scss @@ -2,18 +2,16 @@ width: 100%; height: 100%; background-color: #fff; + display: flex; + align-items: center; + justify-content: center; } .mx_GenericErrorPage_box { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - margin: auto; + display: inline; width: 500px; - height: 200px; + min-height: 125px; border: 1px solid #f22; - padding: 10px; + padding: 10px 10px 20px; background-color: #fcc; } diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index a8d8669285..7d10fdb6d6 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -24,13 +24,13 @@ limitations under the License. .mx_LeftPanel_container.collapsed { min-width: unset; - /* Collapsed LeftPanel 70px */ - flex: 0 0 70px; + /* Collapsed LeftPanel 50px */ + flex: 0 0 50px; } .mx_LeftPanel_container.collapsed.mx_LeftPanel_container_hasTagPanel { - /* TagPanel 70px + Collapsed LeftPanel 70px */ - flex: 0 0 140px; + /* TagPanel 70px + Collapsed LeftPanel 50px */ + flex: 0 0 120px; } .mx_LeftPanel_tagPanelContainer { diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index bcfe3aefd6..1df0a61a2b 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -35,13 +35,6 @@ limitations under the License. flex: 1; } -.mx_RoomDirectory .gm-scroll-view { - // little hack because gemini doesn't seem to detect - // the scrollbar width well in this instance - // when using css scrollbars - scrollbar-width: thin; -} - .mx_RoomDirectory_createRoom { background-color: $button-bg-color; border-radius: 4px; diff --git a/res/css/structures/_RoomSubList.scss b/res/css/structures/_RoomSubList.scss index 15fddba817..3b03fe0a2f 100644 --- a/res/css/structures/_RoomSubList.scss +++ b/res/css/structures/_RoomSubList.scss @@ -143,7 +143,7 @@ limitations under the License. } .mx_RoomSubList_labelContainer { - margin-right: 14px; + margin-right: 8px; margin-left: 2px; } diff --git a/res/css/structures/_TagPanel.scss b/res/css/structures/_TagPanel.scss index a818f52125..b03d36a592 100644 --- a/res/css/structures/_TagPanel.scss +++ b/res/css/structures/_TagPanel.scss @@ -63,7 +63,6 @@ limitations under the License. display: flex; flex-direction: column; align-items: center; - margin-top: 5px; height: 100%; } diff --git a/res/css/structures/_TopLeftMenuButton.scss b/res/css/structures/_TopLeftMenuButton.scss index 94a391ae70..ee03978f18 100644 --- a/res/css/structures/_TopLeftMenuButton.scss +++ b/res/css/structures/_TopLeftMenuButton.scss @@ -22,7 +22,7 @@ limitations under the License. display: flex; align-items: center; min-width: 0; - padding: 0 7px; + padding: 0 4px; overflow: hidden; } diff --git a/res/css/structures/auth/_Login.scss b/res/css/structures/auth/_Login.scss index 2cf6276557..4ce90cc6bd 100644 --- a/res/css/structures/auth/_Login.scss +++ b/res/css/structures/auth/_Login.scss @@ -30,6 +30,7 @@ limitations under the License. .mx_Login_submit:disabled { opacity: 0.3; + cursor: default; } .mx_AuthBody a.mx_Login_sso_link:link, @@ -62,6 +63,15 @@ limitations under the License. margin-bottom: 12px; } +.mx_Login_error.mx_Login_serverError { + text-align: left; + font-weight: normal; +} + +.mx_Login_error.mx_Login_serverError.mx_Login_serverErrorNonFatal { + color: $orange-warning-color; +} + .mx_Login_type_container { display: flex; align-items: center; @@ -73,9 +83,9 @@ limitations under the License. } .mx_Login_type_label { - flex-grow: 1; + flex: 1; } -.mx_Login_type_dropdown { - min-width: 200px; +.mx_Login_underlinedServerName { + border-bottom: 1px dashed $accent-color; } diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index 16ac876869..49a87d8077 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -58,12 +58,12 @@ limitations under the License. color: $authpage-primary-color; } + .mx_Field_labelAlwaysTopLeft label, + .mx_Field select + label /* Always show a select's label on top to not collide with the value */, .mx_Field input:focus + label, .mx_Field input:not(:placeholder-shown) + label, .mx_Field textarea:focus + label, - .mx_Field textarea:not(:placeholder-shown) + label, - .mx_Field select + label /* Always show a select's label on top to not collide with the value */, - .mx_Field_labelAlwaysTopLeft label { + .mx_Field textarea:not(:placeholder-shown) + label { background-color: $authpage-body-bg-color; } @@ -72,7 +72,6 @@ limitations under the License. } .mx_Field input { - width: 100%; box-sizing: border-box; } @@ -110,7 +109,6 @@ limitations under the License. .mx_AuthBody_fieldRow > .mx_Field { margin: 0 5px; - flex: 1; } .mx_AuthBody_fieldRow > .mx_Field:first-child { diff --git a/res/css/views/auth/_InteractiveAuthEntryComponents.scss b/res/css/views/auth/_InteractiveAuthEntryComponents.scss index e2ea7d86fb..85007aeecb 100644 --- a/res/css/views/auth/_InteractiveAuthEntryComponents.scss +++ b/res/css/views/auth/_InteractiveAuthEntryComponents.scss @@ -49,10 +49,14 @@ limitations under the License. } .mx_InteractiveAuthEntryComponents_termsSubmit:disabled { - background-color: $accent-color-50pct; + background-color: $accent-color-darker; cursor: default; } .mx_InteractiveAuthEntryComponents_termsPolicy { display: block; -} \ No newline at end of file +} + +.mx_InteractiveAuthEntryComponents_passwordSection { + width: 300px; +} diff --git a/res/css/views/auth/_ServerConfig.scss b/res/css/views/auth/_ServerConfig.scss index 79ad9e8238..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,24 +15,21 @@ 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 { - flex: 1; - 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; } + +.mx_ServerConfig_error { + 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/context_menus/_RoomTileContextMenu.scss b/res/css/views/context_menus/_RoomTileContextMenu.scss index f832691be4..308cecfe1e 100644 --- a/res/css/views/context_menus/_RoomTileContextMenu.scss +++ b/res/css/views/context_menus/_RoomTileContextMenu.scss @@ -18,6 +18,18 @@ limitations under the License. padding: 6px; } +.mx_RoomTileContextMenu_tag_icon { + padding-right: 8px; + padding-left: 4px; + display: inline-block; +} + +.mx_RoomTileContextMenu_tag_icon_set { + padding-right: 8px; + padding-left: 4px; + display: none; +} + .mx_RoomTileContextMenu_tag_field, .mx_RoomTileContextMenu_leave { padding-top: 8px; padding-right: 20px; @@ -45,18 +57,6 @@ limitations under the License. color: rgba(0, 0, 0, 0.2); } -.mx_RoomTileContextMenu_tag_icon { - padding-right: 8px; - padding-left: 4px; - display: inline-block -} - -.mx_RoomTileContextMenu_tag_icon_set { - padding-right: 8px; - padding-left: 4px; - display: none; -} - .mx_RoomTileContextMenu_separator { margin-top: 0; margin-bottom: 0; @@ -72,10 +72,6 @@ limitations under the License. color: $warning-color; } -.mx_RoomTileContextMenu_tag_fieldSet .mx_RoomTileContextMenu_tag_icon { - /* Something to indicate that the icon is the set tag */ -} - .mx_RoomTileContextMenu_notif_picker { position: absolute; top: 16px; diff --git a/res/css/views/context_menus/_TagTileContextMenu.scss b/res/css/views/context_menus/_TagTileContextMenu.scss index 799151b198..46b279ce2d 100644 --- a/res/css/views/context_menus/_TagTileContextMenu.scss +++ b/res/css/views/context_menus/_TagTileContextMenu.scss @@ -33,7 +33,7 @@ limitations under the License. .mx_TagTileContextMenu_item_icon { padding-right: 8px; padding-left: 4px; - display: inline-block + display: inline-block; } .mx_TagTileContextMenu_separator { diff --git a/res/css/views/context_menus/_TopLeftMenu.scss b/res/css/views/context_menus/_TopLeftMenu.scss index 113da004b8..9d258bcf55 100644 --- a/res/css/views/context_menus/_TopLeftMenu.scss +++ b/res/css/views/context_menus/_TopLeftMenu.scss @@ -89,5 +89,4 @@ limitations under the License. background-color: $menu-selected-color; } } - } diff --git a/res/css/views/dialogs/_AddressPickerDialog.scss b/res/css/views/dialogs/_AddressPickerDialog.scss index b4d4a74cb5..2771ac4052 100644 --- a/res/css/views/dialogs/_AddressPickerDialog.scss +++ b/res/css/views/dialogs/_AddressPickerDialog.scss @@ -17,8 +17,7 @@ limitations under the License. /* Using a textarea for this element, to circumvent autofill */ .mx_AddressPickerDialog_input, -.mx_AddressPickerDialog_input:focus -{ +.mx_AddressPickerDialog_input:focus { height: 26px; font-size: 14px; font-family: $font-family; @@ -36,7 +35,7 @@ limitations under the License. } .mx_AddressPickerDialog .mx_Dialog_content { - min-height: 50px + min-height: 50px; } .mx_AddressPickerDialog_inputContainer { diff --git a/res/css/views/dialogs/_DeactivateAccountDialog.scss b/res/css/views/dialogs/_DeactivateAccountDialog.scss index dc76da5b15..192917b2d0 100644 --- a/res/css/views/dialogs/_DeactivateAccountDialog.scss +++ b/res/css/views/dialogs/_DeactivateAccountDialog.scss @@ -21,3 +21,7 @@ limitations under the License. .mx_DeactivateAccountDialog .mx_DeactivateAccountDialog_input_section { margin-top: 60px; } + +.mx_DeactivateAccountDialog .mx_DeactivateAccountDialog_input_section .mx_Field { + width: 300px; +} diff --git a/res/css/views/dialogs/_DevtoolsDialog.scss b/res/css/views/dialogs/_DevtoolsDialog.scss index 1f5d36b57a..417d0d6744 100644 --- a/res/css/views/dialogs/_DevtoolsDialog.scss +++ b/res/css/views/dialogs/_DevtoolsDialog.scss @@ -23,7 +23,11 @@ limitations under the License. cursor: default !important; } -.mx_DevTools_RoomStateExplorer_button, .mx_DevTools_ServersInRoomList_button, .mx_DevTools_RoomStateExplorer_query { +.mx_DevTools_RoomStateExplorer_query { + margin-bottom: 10px; +} + +.mx_DevTools_RoomStateExplorer_button, .mx_DevTools_ServersInRoomList_button { margin-bottom: 10px; width: 100%; } @@ -41,13 +45,11 @@ limitations under the License. border-bottom: 1px solid #e5e5e5; } -.mx_DevTools_inputRow -{ +.mx_DevTools_inputRow { display: table-row; } -.mx_DevTools_inputLabelCell -{ +.mx_DevTools_inputLabelCell { display: table-cell; font-weight: bold; padding-right: 24px; @@ -58,8 +60,7 @@ limitations under the License. width: 240px; } -.mx_DevTools_inputCell input -{ +.mx_DevTools_inputCell input { display: inline-block; border: 0; border-bottom: 1px solid $input-underline-color; @@ -75,11 +76,6 @@ limitations under the License. max-width: 684px; min-height: 250px; padding: 10px; - width: 100%; -} - -.mx_DevTools_content .mx_Field_input { - display: inline-block; } .mx_DevTools_eventTypeStateKeyGroup { @@ -96,11 +92,11 @@ limitations under the License. // add default box-sizing for this scope &, - &:after, - &:before, + &::after, + &::before, & *, - & *:after, - & *:before, + & *::after, + & *::before, & + .mx_DevTools_tgl-btn { box-sizing: border-box; &::selection { @@ -116,8 +112,8 @@ limitations under the License. position: relative; cursor: pointer; user-select: none; - &:after, - &:before { + &::after, + &::before { position: relative; display: block; content: ""; @@ -125,28 +121,31 @@ limitations under the License. height: 100%; } - &:after { + &::after { left: 0; } - &:before { + &::before { display: none; } } - &:checked + .mx_DevTools_tgl-btn:after { + &:checked + .mx_DevTools_tgl-btn::after { left: 50%; } } +/* Ordering this block by specificity would require breaking it up into several + chunks, which seems like it would be more confusing to read. */ +/* stylelint-disable no-descending-specificity */ .mx_DevTools_tgl-flip { + .mx_DevTools_tgl-btn { padding: 2px; transition: all .2s ease; font-family: sans-serif; perspective: 100px; - &:after, - &:before { + &::after, + &::before { display: inline-block; transition: all .4s ease; width: 100%; @@ -161,35 +160,36 @@ limitations under the License. border-radius: 4px; } - &:after { + &::after { content: attr(data-tg-on); - background: #02C66F; + background: #02c66f; transform: rotateY(-180deg); } - &:before { - background: #FF3A19; + &::before { + background: #ff3a19; content: attr(data-tg-off); } - &:active:before { + &:active::before { transform: rotateY(-20deg); } } &:checked + .mx_DevTools_tgl-btn { - &:before { + &::before { transform: rotateY(180deg); } - &:after { + &::after { transform: rotateY(0); left: 0; - background: #7FC6A6; + background: #7fc6a6; } - &:active:after { + &:active::after { transform: rotateY(20deg); } } } +/* stylelint-enable no-descending-specificity */ diff --git a/res/css/views/dialogs/_GroupAddressPicker.scss b/res/css/views/dialogs/_GroupAddressPicker.scss index d6c961c0ec..20a7cc1047 100644 --- a/res/css/views/dialogs/_GroupAddressPicker.scss +++ b/res/css/views/dialogs/_GroupAddressPicker.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_GroupAddressPicker_checkboxContainer{ +.mx_GroupAddressPicker_checkboxContainer { margin-top: 10px; display: flex; } diff --git a/res/css/views/dialogs/_MessageEditHistoryDialog.scss b/res/css/views/dialogs/_MessageEditHistoryDialog.scss new file mode 100644 index 0000000000..0066faccae --- /dev/null +++ b/res/css/views/dialogs/_MessageEditHistoryDialog.scss @@ -0,0 +1,67 @@ +/* +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_MessageEditHistoryDialog .mx_Dialog_header > .mx_Dialog_title { + text-align: center; +} + +.mx_MessageEditHistoryDialog { + display: flex; + flex-direction: column; + max-height: 60vh; +} + +.mx_MessageEditHistoryDialog_scrollPanel { + flex: 1 1 auto; +} + +.mx_MessageEditHistoryDialog_error { + color: $warning-color; + text-align: center; +} + +.mx_MessageEditHistoryDialog_edits { + list-style-type: none; + font-size: 14px; + padding: 0; + color: $primary-fg-color; + + span.mx_EditHistoryMessage_deletion, span.mx_EditHistoryMessage_insertion { + padding: 0px 2px; + } + + .mx_EditHistoryMessage_deletion { + color: rgb(255, 76, 85); + background-color: rgba(255, 76, 85, 0.1); + text-decoration: line-through; + } + + .mx_EditHistoryMessage_insertion { + color: rgb(26, 169, 123); + background-color: rgba(26, 169, 123, 0.1); + text-decoration: underline; + } + + .mx_EventTile_line, .mx_EventTile_content { + margin-right: 0px; + } + + .mx_MessageActionBar .mx_AccessibleButton { + font-size: 10px; + padding: 0 8px; + } +} + diff --git a/res/css/views/dialogs/_RoomSettingsDialog.scss b/res/css/views/dialogs/_RoomSettingsDialog.scss index 60b35528a1..723eb237ad 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.scss +++ b/res/css/views/dialogs/_RoomSettingsDialog.scss @@ -17,19 +17,19 @@ limitations under the License. // ICONS // ========================================================== -.mx_RoomSettingsDialog_settingsIcon:before { +.mx_RoomSettingsDialog_settingsIcon::before { mask-image: url('$(res)/img/feather-customised/settings.svg'); } -.mx_RoomSettingsDialog_securityIcon:before { +.mx_RoomSettingsDialog_securityIcon::before { mask-image: url('$(res)/img/feather-customised/lock.svg'); } -.mx_RoomSettingsDialog_rolesIcon:before { +.mx_RoomSettingsDialog_rolesIcon::before { mask-image: url('$(res)/img/feather-customised/users-sm.svg'); } -.mx_RoomSettingsDialog_warningIcon:before { +.mx_RoomSettingsDialog_warningIcon::before { mask-image: url('$(res)/img/feather-customised/warning-triangle.svg'); } diff --git a/res/css/views/dialogs/_SetEmailDialog.scss b/res/css/views/dialogs/_SetEmailDialog.scss index 588f10c9cb..9d09a208df 100644 --- a/res/css/views/dialogs/_SetEmailDialog.scss +++ b/res/css/views/dialogs/_SetEmailDialog.scss @@ -31,6 +31,3 @@ limitations under the License. box-shadow: none; border: 1px solid $accent-color; } - -.mx_SetEmailDialog_email_input_placeholder { -} diff --git a/res/css/views/dialogs/_SetPasswordDialog.scss b/res/css/views/dialogs/_SetPasswordDialog.scss index 28a8b7c9d7..325ff6c6ed 100644 --- a/res/css/views/dialogs/_SetPasswordDialog.scss +++ b/res/css/views/dialogs/_SetPasswordDialog.scss @@ -21,7 +21,6 @@ limitations under the License. color: $primary-fg-color; background-color: $primary-bg-color; font-size: 15px; - width: 100%; max-width: 280px; margin-bottom: 10px; } diff --git a/res/css/views/dialogs/_BugReportDialog.scss b/res/css/views/dialogs/_SlashCommandHelpDialog.scss similarity index 68% rename from res/css/views/dialogs/_BugReportDialog.scss rename to res/css/views/dialogs/_SlashCommandHelpDialog.scss index 90ef55b945..786a28deef 100644 --- a/res/css/views/dialogs/_BugReportDialog.scss +++ b/res/css/views/dialogs/_SlashCommandHelpDialog.scss @@ -1,5 +1,5 @@ /* -Copyright 2017 OpenMarket Ltd +Copyright 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. @@ -14,12 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_BugReportDialog .mx_Field { - flex: 1; +.mx_SlashCommandHelpDialog .mx_SlashCommandHelpDialog_headerRow h2 { + margin-bottom: 2px; } -.mx_BugReportDialog_field_input { - // TODO: We should really apply this to all .mx_Field inputs. - // See https://github.com/vector-im/riot-web/issues/9344. - flex: 1; +.mx_SlashCommandHelpDialog .mx_Dialog_content { + margin-top: 12px; + margin-bottom: 34px; } 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/dialogs/_TermsDialog.scss b/res/css/views/dialogs/_TermsDialog.scss new file mode 100644 index 0000000000..df2a72010f --- /dev/null +++ b/res/css/views/dialogs/_TermsDialog.scss @@ -0,0 +1,47 @@ +/* +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. +*/ + +/* + * To avoid visual glitching of two modals stacking briefly, we customise the + * terms dialog sizing when it will appear for the integrations manager so that + * it gets the same basic size as the IM's own modal. + */ +.mx_TermsDialog_forIntegrationsManager .mx_Dialog { + width: 60%; + height: 70%; + box-sizing: border-box; +} + +.mx_TermsDialog_termsTableHeader { + font-weight: bold; + text-align: left; +} + +.mx_TermsDialog_termsTable { + font-size: 12px; + width: 100%; +} + +.mx_TermsDialog_service, .mx_TermsDialog_summary { + padding-right: 10px; +} + +.mx_TermsDialog_link { + mask-image: url('$(res)/img/external-link.svg'); + background-color: $accent-color; + width: 10px; + height: 10px; +} diff --git a/res/css/views/dialogs/_UserSettingsDialog.scss b/res/css/views/dialogs/_UserSettingsDialog.scss index 9665ee06b4..2a046ff501 100644 --- a/res/css/views/dialogs/_UserSettingsDialog.scss +++ b/res/css/views/dialogs/_UserSettingsDialog.scss @@ -17,34 +17,34 @@ limitations under the License. // ICONS // ========================================================== -.mx_UserSettingsDialog_settingsIcon:before { +.mx_UserSettingsDialog_settingsIcon::before { mask-image: url('$(res)/img/feather-customised/settings.svg'); } -.mx_UserSettingsDialog_voiceIcon:before { +.mx_UserSettingsDialog_voiceIcon::before { mask-image: url('$(res)/img/feather-customised/phone.svg'); } -.mx_UserSettingsDialog_bellIcon:before { +.mx_UserSettingsDialog_bellIcon::before { mask-image: url('$(res)/img/feather-customised/notifications.svg'); } -.mx_UserSettingsDialog_preferencesIcon:before { +.mx_UserSettingsDialog_preferencesIcon::before { mask-image: url('$(res)/img/feather-customised/sliders.svg'); } -.mx_UserSettingsDialog_securityIcon:before { +.mx_UserSettingsDialog_securityIcon::before { mask-image: url('$(res)/img/feather-customised/lock.svg'); } -.mx_UserSettingsDialog_helpIcon:before { +.mx_UserSettingsDialog_helpIcon::before { mask-image: url('$(res)/img/feather-customised/help-circle.svg'); } -.mx_UserSettingsDialog_labsIcon:before { +.mx_UserSettingsDialog_labsIcon::before { mask-image: url('$(res)/img/feather-customised/flag.svg'); } -.mx_UserSettingsDialog_flairIcon:before { +.mx_UserSettingsDialog_flairIcon::before { mask-image: url('$(res)/img/feather-customised/flair.svg'); } diff --git a/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss b/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss index 3f4c8d2da4..7ba5f01a76 100644 --- a/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss +++ b/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss @@ -20,8 +20,8 @@ limitations under the License. } .mx_CreateKeyBackupDialog_primaryContainer { - /*FIXME: plinth colour in new theme(s). background-color: $accent-color;*/ - padding: 20px + /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ + padding: 20px; } .mx_CreateKeyBackupDialog_primaryContainer::after { diff --git a/res/css/views/dialogs/keybackup/_KeyBackupFailedDialog.scss b/res/css/views/dialogs/keybackup/_KeyBackupFailedDialog.scss index f905516bd4..05ce158413 100644 --- a/res/css/views/dialogs/keybackup/_KeyBackupFailedDialog.scss +++ b/res/css/views/dialogs/keybackup/_KeyBackupFailedDialog.scss @@ -23,7 +23,7 @@ limitations under the License. padding-left: 45px; padding-bottom: 10px; - &:before { + &::before { mask: url("$(res)/img/e2e/lock-warning-filled.svg"); mask-repeat: no-repeat; background-color: $primary-fg-color; diff --git a/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss b/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss index 612c921038..415a2021cc 100644 --- a/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss +++ b/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss @@ -13,10 +13,10 @@ 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_RestoreKeyBackupDialog_primaryContainer { - /*FIXME: plinth colour in new theme(s). background-color: $accent-color;*/ - padding: 20px + /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ + padding: 20px; } .mx_RestoreKeyBackupDialog_passPhraseInput, diff --git a/res/css/views/directory/_NetworkDropdown.scss b/res/css/views/directory/_NetworkDropdown.scss index 8d5fa5dc7b..d402f6c48f 100644 --- a/res/css/views/directory/_NetworkDropdown.scss +++ b/res/css/views/directory/_NetworkDropdown.scss @@ -36,7 +36,7 @@ limitations under the License. position: absolute; right: 10px; top: 16px; - width: 0 + width: 0; } .mx_NetworkDropdown_networkoption { diff --git a/res/css/views/elements/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index fe1f283009..0c081ec0d5 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_AccessibleButton:focus { - outline: 0; -} - .mx_AccessibleButton { cursor: pointer; } +.mx_AccessibleButton:focus { + outline: 0; +} + .mx_AccessibleButton_disabled { cursor: default; } @@ -79,3 +79,22 @@ limitations under the License. color: $button-danger-disabled-fg-color; background-color: $button-danger-disabled-bg-color; } + +.mx_AccessibleButton_kind_link { + color: $button-link-fg-color; + background-color: $button-link-bg-color; +} + +.mx_AccessibleButton_kind_link.mx_AccessibleButton_disabled { + opacity: 0.4; +} + +.mx_AccessibleButton_hasKind.mx_AccessibleButton_kind_link_sm { + padding: 5px 12px; + color: $button-link-fg-color; + background-color: $button-link-bg-color; +} + +.mx_AccessibleButton_kind_link_sm.mx_AccessibleButton_disabled { + opacity: 0.4; +} diff --git a/res/css/views/elements/_AddressSelector.scss b/res/css/views/elements/_AddressSelector.scss index 9871a7e881..dd78fcc0f0 100644 --- a/res/css/views/elements/_AddressSelector.scss +++ b/res/css/views/elements/_AddressSelector.scss @@ -20,9 +20,8 @@ limitations under the License. width: 485px; max-height: 116px; overflow-y: auto; - border-radius: 3px; - background-color: $primary-bg-color; - border: solid 1px $accent-color; + border-radius: 3px; + border: solid 1px $accent-color; cursor: pointer; } diff --git a/res/css/views/elements/_Dropdown.scss b/res/css/views/elements/_Dropdown.scss index 2a59393499..102ac56bf9 100644 --- a/res/css/views/elements/_Dropdown.scss +++ b/res/css/views/elements/_Dropdown.scss @@ -51,13 +51,6 @@ limitations under the License. background: $primary-fg-color; } -.mx_Dropdown_input > .mx_Dropdown_option { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; -} - .mx_Dropdown_option { height: 35px; line-height: 35px; @@ -65,6 +58,13 @@ limitations under the License. padding-right: 8px; } +.mx_Dropdown_input > .mx_Dropdown_option { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + .mx_Dropdown_option div { overflow: hidden; text-overflow: ellipsis; diff --git a/res/css/views/elements/_EditableItemList.scss b/res/css/views/elements/_EditableItemList.scss index be96d811d3..51fa4c4423 100644 --- a/res/css/views/elements/_EditableItemList.scss +++ b/res/css/views/elements/_EditableItemList.scss @@ -42,12 +42,6 @@ limitations under the License. margin-right: 5px; } -.mx_EditableItemList_newItem .mx_Field input { - // Use 100% of the space available for the input, but don't let the 10px - // padding on either side of the input to push it out of alignment. - width: calc(100% - 20px); -} - .mx_EditableItemList_label { margin-bottom: 5px; -} \ No newline at end of file +} diff --git a/res/css/views/elements/_Field.scss b/res/css/views/elements/_Field.scss index 147bb3b471..0e8252e89d 100644 --- a/res/css/views/elements/_Field.scss +++ b/res/css/views/elements/_Field.scss @@ -18,6 +18,8 @@ limitations under the License. .mx_Field { display: flex; + flex: 1; + min-width: 0; position: relative; margin: 1em 0; border-radius: 4px; @@ -42,6 +44,7 @@ limitations under the License. padding: 8px 9px; color: $primary-fg-color; background-color: $primary-bg-color; + flex: 1; } .mx_Field select { @@ -107,12 +110,12 @@ limitations under the License. max-width: calc(100% - 20px); // 100% of parent minus margin and padding } +.mx_Field_labelAlwaysTopLeft label, +.mx_Field select + label /* Always show a select's label on top to not collide with the value */, .mx_Field input:focus + label, .mx_Field input:not(:placeholder-shown) + label, .mx_Field textarea:focus + label, -.mx_Field textarea:not(:placeholder-shown) + label, -.mx_Field select + label /* Always show a select's label on top to not collide with the value */, -.mx_Field_labelAlwaysTopLeft label { +.mx_Field textarea:not(:placeholder-shown) + label { transition: font-size 0.25s ease-out 0s, color 0.25s ease-out 0s, @@ -141,6 +144,9 @@ limitations under the License. color: $greyed-fg-color; } +/* Ordering this block by specificity would require breaking it up into several + chunks, which seems like it would be more confusing to read. */ +/* stylelint-disable no-descending-specificity */ .mx_Field_valid { &.mx_Field, &.mx_Field:focus-within { @@ -164,6 +170,7 @@ limitations under the License. color: $input-invalid-border-color; } } +/* stylelint-enable no-descending-specificity */ .mx_Field_tooltip { margin-top: -12px; diff --git a/res/css/views/elements/_ImageView.scss b/res/css/views/elements/_ImageView.scss index 88cf2ce8ba..67b0d6d7df 100644 --- a/res/css/views/elements/_ImageView.scss +++ b/res/css/views/elements/_ImageView.scss @@ -128,8 +128,8 @@ limitations under the License. } .mx_ImageView_link { - color: $lightbox-fg-color ! important; - text-decoration: none ! important; + color: $lightbox-fg-color !important; + text-decoration: none !important; } .mx_ImageView_button { diff --git a/res/css/views/elements/_InteractiveTooltip.scss b/res/css/views/elements/_InteractiveTooltip.scss new file mode 100644 index 0000000000..17a76436e8 --- /dev/null +++ b/res/css/views/elements/_InteractiveTooltip.scss @@ -0,0 +1,91 @@ +/* +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_InteractiveTooltip_wrapper { + position: fixed; + z-index: 5000; +} + +.mx_InteractiveTooltip { + border-radius: 3px; + background-color: $interactive-tooltip-bg-color; + color: $interactive-tooltip-fg-color; + position: absolute; + font-size: 10px; + font-weight: 600; + padding: 6px; + z-index: 5001; +} + +.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_top { + top: 10px; // 8px chevron + 2px spacing +} + +.mx_InteractiveTooltip_chevron_top { + position: absolute; + left: calc(50% - 8px); + top: -8px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-bottom: 8px solid $interactive-tooltip-bg-color; + border-right: 8px solid transparent; +} + +// Adapted from https://codyhouse.co/blog/post/css-rounded-triangles-with-clip-path +// by Sebastiano Guerriero (@guerriero_se) +@supports (clip-path: polygon(0% 0%, 100% 100%, 0% 100%)) { + .mx_InteractiveTooltip_chevron_top { + height: 16px; + width: 16px; + background-color: inherit; + border: none; + clip-path: polygon(0% 0%, 100% 100%, 0% 100%); + transform: rotate(135deg); + border-radius: 0 0 0 3px; + top: calc(-8px / 1.414); // sqrt(2) because of rotation + } +} + +.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_bottom { + bottom: 10px; // 8px chevron + 2px spacing +} + +.mx_InteractiveTooltip_chevron_bottom { + position: absolute; + left: calc(50% - 8px); + bottom: -8px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-top: 8px solid $interactive-tooltip-bg-color; + border-right: 8px solid transparent; +} + +// Adapted from https://codyhouse.co/blog/post/css-rounded-triangles-with-clip-path +// by Sebastiano Guerriero (@guerriero_se) +@supports (clip-path: polygon(0% 0%, 100% 100%, 0% 100%)) { + .mx_InteractiveTooltip_chevron_bottom { + height: 16px; + width: 16px; + background-color: inherit; + border: none; + clip-path: polygon(0% 0%, 100% 100%, 0% 100%); + transform: rotate(-45deg); + border-radius: 0 0 0 3px; + bottom: calc(-8px / 1.414); // sqrt(2) because of rotation + } +} diff --git a/res/css/views/elements/_ManageIntegsButton.scss b/res/css/views/elements/_ManageIntegsButton.scss index 7c91b9dbde..fe8c76003b 100644 --- a/res/css/views/elements/_ManageIntegsButton.scss +++ b/res/css/views/elements/_ManageIntegsButton.scss @@ -16,6 +16,7 @@ limitations under the License. .mx_ManageIntegsButton_error { position: relative; + float: right; cursor: not-allowed; } @@ -25,18 +26,6 @@ limitations under the License. top: -5px; } -.mx_ManageIntegsButton_error { - float: right; -} - -.mx_ManageIntegsButton_error .mx_ManageIntegsButton_errorPopup { - display: none; -} - -.mx_ManageIntegsButton_error:hover .mx_ManageIntegsButton_errorPopup { - display: inline; -} - .mx_ManageIntegsButton_errorPopup { position: absolute; top: 110%; @@ -51,3 +40,11 @@ limitations under the License. text-align: center; z-index: 1000; } + +.mx_ManageIntegsButton_error .mx_ManageIntegsButton_errorPopup { + display: none; +} + +.mx_ManageIntegsButton_error:hover .mx_ManageIntegsButton_errorPopup { + display: inline; +} diff --git a/res/css/views/elements/_PowerSelector.scss b/res/css/views/elements/_PowerSelector.scss index 69f3a8eebb..799f6f246e 100644 --- a/res/css/views/elements/_PowerSelector.scss +++ b/res/css/views/elements/_PowerSelector.scss @@ -20,6 +20,5 @@ limitations under the License. .mx_PowerSelector .mx_Field select, .mx_PowerSelector .mx_Field input { - width: 100%; box-sizing: border-box; } diff --git a/res/css/views/elements/_RichText.scss b/res/css/views/elements/_RichText.scss index cf2c066589..73f0be291f 100644 --- a/res/css/views/elements/_RichText.scss +++ b/res/css/views/elements/_RichText.scss @@ -13,12 +13,6 @@ padding-left: 5px; } -.mx_EventTile_body .mx_UserPill, -.mx_EventTile_body .mx_RoomPill, -.mx_EventTile_body .mx_GroupPill { - cursor: pointer; -} - /* More specific to override `.markdown-body a` text-decoration */ .mx_EventTile_content .markdown-body a.mx_Pill { text-decoration: none; @@ -33,7 +27,7 @@ } .mx_UserPill_selected { - background-color: $accent-color ! important; + background-color: $accent-color !important; } /* More specific to override `.markdown-body a` color */ @@ -64,6 +58,12 @@ padding-right: 5px; } +.mx_EventTile_body .mx_UserPill, +.mx_EventTile_body .mx_RoomPill, +.mx_EventTile_body .mx_GroupPill { + cursor: pointer; +} + .mx_UserPill .mx_BaseAvatar, .mx_RoomPill .mx_BaseAvatar, .mx_GroupPill .mx_BaseAvatar, @@ -79,7 +79,7 @@ .mx_Markdown_ITALIC { font-style: italic; -/* + /* // interestingly, *not* using the explicit italic font // variant seems yield better results. @@ -87,7 +87,7 @@ // https://github.com/google/fonts/issues/1726 transform: skewX(-14deg); display: inline-block; -*/ + */ } .mx_Markdown_CODE { diff --git a/res/css/views/elements/_Spinner.scss b/res/css/views/elements/_Spinner.scss index aea5737918..01b4f23c2c 100644 --- a/res/css/views/elements/_Spinner.scss +++ b/res/css/views/elements/_Spinner.scss @@ -25,4 +25,4 @@ limitations under the License. .mx_MatrixChat_middlePanel .mx_Spinner { height: auto; -} \ No newline at end of file +} diff --git a/res/css/views/elements/_TextWithTooltip.scss b/res/css/views/elements/_TextWithTooltip.scss new file mode 100644 index 0000000000..a7f9cb7483 --- /dev/null +++ b/res/css/views/elements/_TextWithTooltip.scss @@ -0,0 +1,19 @@ +/* +Copyright 2019 New Vector Ltd. + +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_TextWithTooltip_tooltip { + display: none; +} diff --git a/res/css/views/elements/_ToggleSwitch.scss b/res/css/views/elements/_ToggleSwitch.scss index 1bb3a74ab1..1f4445b88c 100644 --- a/res/css/views/elements/_ToggleSwitch.scss +++ b/res/css/views/elements/_ToggleSwitch.scss @@ -44,10 +44,10 @@ limitations under the License. top: 0; } -.mx_ToggleSwitch:not(.mx_ToggleSwitch_on) > .mx_ToggleSwitch_ball { - left: 2px; -} - .mx_ToggleSwitch_on > .mx_ToggleSwitch_ball { left: 23px; // 48px switch - 20px ball - 5px padding = 23px } + +.mx_ToggleSwitch:not(.mx_ToggleSwitch_on) > .mx_ToggleSwitch_ball { + left: 2px; +} diff --git a/res/css/views/elements/_Tooltip.scss b/res/css/views/elements/_Tooltip.scss index 3a6b6fb936..cc4eb409df 100644 --- a/res/css/views/elements/_Tooltip.scss +++ b/res/css/views/elements/_Tooltip.scss @@ -36,8 +36,8 @@ limitations under the License. border-bottom: 7px solid transparent; } -.mx_Tooltip_chevron:after { - content:''; +.mx_Tooltip_chevron::after { + content: ''; width: 0; height: 0; border-top: 6px solid transparent; @@ -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/elements/_ToolTipButton.scss b/res/css/views/elements/_TooltipButton.scss similarity index 87% rename from res/css/views/elements/_ToolTipButton.scss rename to res/css/views/elements/_TooltipButton.scss index c496e67515..6ea36c800e 100644 --- a/res/css/views/elements/_ToolTipButton.scss +++ b/res/css/views/elements/_TooltipButton.scss @@ -1,5 +1,6 @@ /* Copyright 2017 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,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ToolTipButton { +.mx_TooltipButton { display: inline-block; width: 11px; height: 11px; @@ -33,17 +34,17 @@ limitations under the License. cursor: pointer; } -.mx_ToolTipButton:hover { +.mx_TooltipButton:hover { opacity: 1.0; } -.mx_ToolTipButton_container { +.mx_TooltipButton_container { position: relative; top: -18px; left: 4px; } -.mx_ToolTipButton_helpText { +.mx_TooltipButton_helpText { width: 400px; text-align: start; line-height: 17px !important; diff --git a/res/css/views/globals/_MatrixToolbar.scss b/res/css/views/globals/_MatrixToolbar.scss index 1791d619ae..5fdf572f99 100644 --- a/res/css/views/globals/_MatrixToolbar.scss +++ b/res/css/views/globals/_MatrixToolbar.scss @@ -44,10 +44,9 @@ limitations under the License. flex: 1; } -.mx_MatrixToolbar_link -{ - color: $accent-fg-color ! important; - text-decoration: underline ! important; +.mx_MatrixToolbar_link { + color: $accent-fg-color !important; + text-decoration: underline !important; cursor: pointer; } diff --git a/res/css/views/messages/_CreateEvent.scss b/res/css/views/messages/_CreateEvent.scss index adf16d6c4a..d45645863f 100644 --- a/res/css/views/messages/_CreateEvent.scss +++ b/res/css/views/messages/_CreateEvent.scss @@ -37,6 +37,3 @@ limitations under the License. .mx_CreateEvent_header { font-weight: bold; } - -.mx_CreateEvent_link { -} diff --git a/res/css/views/messages/_DateSeparator.scss b/res/css/views/messages/_DateSeparator.scss index f8738f10e3..935ee1aba3 100644 --- a/res/css/views/messages/_DateSeparator.scss +++ b/res/css/views/messages/_DateSeparator.scss @@ -30,7 +30,7 @@ limitations under the License. border-bottom: 1px solid $panel-divider-color; } -.mx_DateSeparator > date { +.mx_DateSeparator > div { margin: 0 25px; flex: 0 0 auto; } diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 749cfeebe6..c032051c36 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.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. @@ -26,11 +27,30 @@ limitations under the License. top: -18px; right: 8px; user-select: none; + // Ensure the action bar appears above over things, like the read marker. + z-index: 1; + + // Adds a previous event safe area so that you can't accidentally hover the + // previous event while trying to mouse into the action bar or from the + // react button to its tooltip. + &::before { + content: ''; + position: absolute; + // tooltip safe mousing area + tooltip overhang + + // action bar + action bar offset from event + width: calc(10px + 48px + 100% + 8px); + // safe area + action bar + height: calc(20px + 100%); + top: -20px; + left: -58px; + z-index: -1; + cursor: initial; + } > * { + white-space: nowrap; display: inline-block; position: relative; - width: 27px; border: 1px solid $message-action-bar-border-color; margin-left: -1px; @@ -53,6 +73,11 @@ limitations under the License. } } + +.mx_MessageActionBar_maskButton { + width: 27px; +} + .mx_MessageActionBar_maskButton::after { content: ''; position: absolute; @@ -65,6 +90,10 @@ limitations under the License. background-color: $message-action-bar-fg-color; } +.mx_MessageActionBar_reactButton::after { + mask-image: url('$(res)/img/react.svg'); +} + .mx_MessageActionBar_replyButton::after { mask-image: url('$(res)/img/reply.svg'); } diff --git a/res/css/views/messages/_MessageTimestamp.scss b/res/css/views/messages/_MessageTimestamp.scss index e21189c59e..e5c228aa68 100644 --- a/res/css/views/messages/_MessageTimestamp.scss +++ b/res/css/views/messages/_MessageTimestamp.scss @@ -15,4 +15,6 @@ limitations under the License. */ .mx_MessageTimestamp { + color: $event-timestamp-color; + font-size: 10px; } diff --git a/res/css/views/messages/_ReactionQuickTooltip.scss b/res/css/views/messages/_ReactionQuickTooltip.scss new file mode 100644 index 0000000000..7b1611483b --- /dev/null +++ b/res/css/views/messages/_ReactionQuickTooltip.scss @@ -0,0 +1,29 @@ +/* +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_ReactionsQuickTooltip_buttons { + display: grid; + grid-template-columns: repeat(4, auto); +} + +.mx_ReactionsQuickTooltip_label { + text-align: center; +} + +.mx_ReactionsQuickTooltip_shortcode { + padding-left: 6px; + opacity: 0.7; +} diff --git a/res/css/views/messages/_ReactionTooltipButton.scss b/res/css/views/messages/_ReactionTooltipButton.scss new file mode 100644 index 0000000000..59244ab63b --- /dev/null +++ b/res/css/views/messages/_ReactionTooltipButton.scss @@ -0,0 +1,31 @@ +/* +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_ReactionTooltipButton { + font-size: 16px; + padding: 6px; + user-select: none; + cursor: pointer; + transition: transform 0.25s; + + &:hover { + transform: scale(1.2); + } +} + +.mx_ReactionTooltipButton_selected { + opacity: 0.4; +} diff --git a/res/css/views/messages/_ReactionsRow.scss b/res/css/views/messages/_ReactionsRow.scss index fb66ffbb8c..57c02ed3e5 100644 --- a/res/css/views/messages/_ReactionsRow.scss +++ b/res/css/views/messages/_ReactionsRow.scss @@ -16,4 +16,19 @@ limitations under the License. .mx_ReactionsRow { margin: 6px 0; + color: $primary-fg-color; +} + +.mx_ReactionsRow_showAll { + text-decoration: none; + font-size: 10px; + font-weight: 600; + margin-left: 6px; + vertical-align: top; + + &:hover, + &:link, + &:visited { + color: $accent-color; + } } diff --git a/res/css/views/messages/_ReactionsRowButton.scss b/res/css/views/messages/_ReactionsRowButton.scss index 3c6d019b30..e54201d963 100644 --- a/res/css/views/messages/_ReactionsRowButton.scss +++ b/res/css/views/messages/_ReactionsRowButton.scss @@ -15,7 +15,7 @@ limitations under the License. */ .mx_ReactionsRowButton { - display: inline-block; + display: inline-flex; height: 20px; line-height: 21px; margin-right: 6px; @@ -35,3 +35,11 @@ limitations under the License. border-color: $reaction-row-button-selected-border-color; } } + +.mx_ReactionsRowButton_content { + max-width: 100px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding-right: 4px; +} diff --git a/res/css/views/messages/_RoomAvatarEvent.scss b/res/css/views/messages/_RoomAvatarEvent.scss index 9adce42eef..3db18c1d10 100644 --- a/res/css/views/messages/_RoomAvatarEvent.scss +++ b/res/css/views/messages/_RoomAvatarEvent.scss @@ -23,4 +23,4 @@ limitations under the License. display: inline; position: relative; top: 5px; -} \ No newline at end of file +} diff --git a/res/css/views/room_settings/_ColorSettings.scss b/res/css/views/room_settings/_ColorSettings.scss index 39b087653d..fc6a4443ad 100644 --- a/res/css/views/room_settings/_ColorSettings.scss +++ b/res/css/views/room_settings/_ColorSettings.scss @@ -28,7 +28,7 @@ limitations under the License. position: absolute; left: 10px; top: 4px; - cursor: default ! important; + cursor: default !important; } .mx_ColorSettings_roomColorPrimary { diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index db38eebfca..9ca6954af7 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.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. @@ -42,7 +43,6 @@ $AppsDrawerBodyHeight: 273px; .mx_AddWidget_button { order: 2; cursor: pointer; - padding-right: 12px; padding: 0; margin: 5px auto 5px auto; color: $accent-color; @@ -198,7 +198,7 @@ $AppsDrawerBodyHeight: 273px; border-radius: 2px; } -.mx_AppTileBody{ +.mx_AppTileBody { height: $AppsDrawerBodyHeight; width: 100%; overflow: hidden; @@ -234,7 +234,7 @@ $AppsDrawerBodyHeight: 273px; background-color: $lightbox-bg-color; border: 1px solid rgba(0, 0, 0, 0); width: 200px; - box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2); + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2); transition: 0.3s; border-radius: 3px; margin: 5px; @@ -248,7 +248,7 @@ $AppsDrawerBodyHeight: 273px; .mx_AppIconTile:hover { border: 1px solid $accent-color; - box-shadow: 0 0 10px 5px rgba(200,200,200,0.5); + box-shadow: 0 0 10px 5px rgba(200, 200, 200, 0.5); } .mx_AppIconTile_content { @@ -270,9 +270,8 @@ $AppsDrawerBodyHeight: 273px; .mx_AppIconTile_image { padding: 10px; - width: 75%; - max-width:100px; - max-height:100px; + max-width: 100px; + max-height: 100px; width: auto; height: auto; } @@ -312,7 +311,7 @@ form.mx_Custom_Widget_Form div { } .mx_AppPermissionWarningText { - max-width: 400px; + max-width: 90%; margin: 10px auto 10px auto; color: $primary-fg-color; } @@ -323,7 +322,12 @@ form.mx_Custom_Widget_Form div { } .mx_AppPermissionWarningTextURL { + display: inline-block; + max-width: 100%; color: $accent-color; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } .mx_AppPermissionButton { @@ -361,11 +365,11 @@ form.mx_Custom_Widget_Form div { } @keyframes mx_AppLoading_spinner_fadeIn_animation { - from { opacity: 0 } - to { opacity: 1 } + from { opacity: 0; } + to { opacity: 1; } } .mx_AppLoading iframe { - display: none; + display: none; } diff --git a/res/css/views/rooms/_Autocomplete.scss b/res/css/views/rooms/_Autocomplete.scss index 3bc0ea25a4..e5316f5a46 100644 --- a/res/css/views/rooms/_Autocomplete.scss +++ b/res/css/views/rooms/_Autocomplete.scss @@ -8,18 +8,13 @@ border-bottom: none; border-radius: 4px 4px 0 0; max-height: 50vh; - overflow: auto + overflow: auto; } .mx_Autocomplete_ProviderSection { border-bottom: 1px solid $primary-hairline-color; } -.mx_Autocomplete_Completion_container_pill { - margin: 12px; - display: flex; -} - /* a "block" completion takes up a whole line */ .mx_Autocomplete_Completion_block { height: 34px; @@ -32,7 +27,7 @@ } .mx_Autocomplete_Completion_block * { - margin: 0 3px; + margin: 0 3px; } .mx_Autocomplete_Completion_pill { @@ -50,11 +45,27 @@ margin: 0 3px; } +/* styling for common completion elements */ +.mx_Autocomplete_Completion_subtitle { + font-style: italic; + flex: 1; +} + +.mx_Autocomplete_Completion_description { + color: gray; +} + +.mx_Autocomplete_Completion_container_pill { + margin: 12px; + display: flex; + flex-flow: wrap; +} + .mx_Autocomplete_Completion_container_truncate { .mx_Autocomplete_Completion_title, .mx_Autocomplete_Completion_subtitle, .mx_Autocomplete_Completion_description { - /* Ellipsis for long names/subtitles/descriptions*/ + /* Ellipsis for long names/subtitles/descriptions */ max-width: 150px; white-space: nowrap; overflow: hidden; @@ -62,13 +73,6 @@ } } -/* container for pill-style completions */ -.mx_Autocomplete_Completion_container_pill { - margin: 12px; - display: flex; - flex-flow: wrap; -} - .mx_Autocomplete_Completion.selected, .mx_Autocomplete_Completion:hover { background: $selected-color; @@ -81,14 +85,3 @@ font-weight: 400; opacity: 0.4; } - -/* styling for common completion elements */ -.mx_Autocomplete_Completion_subtitle { - font-style: italic; - flex: 1; -} - -.mx_Autocomplete_Completion_description { - color: gray; -} - diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/rooms/_BasicMessageComposer.scss similarity index 57% rename from res/css/views/elements/_MessageEditor.scss rename to res/css/views/rooms/_BasicMessageComposer.scss index e721b267fa..bce0ecf325 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/rooms/_BasicMessageComposer.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,24 +15,31 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_MessageEditor { - border-radius: 4px; - padding: 3px; - // this is to try not make the text move but still have some - // padding around and in the editor. - // Actual values from fiddling around in inspector - margin: -7px -10px -5px -10px; - overflow: visible !important; // override mx_EventTile_content +.mx_BasicMessageComposer { + .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; + } - .mx_MessageEditor_editor { - border-radius: 4px; - border: solid 1px $primary-hairline-color; - background-color: $primary-bg-color; - padding: 3px 6px; + @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; - max-height: 200px; overflow-x: auto; span.mx_UserPill, span.mx_RoomPill { @@ -59,32 +67,8 @@ limitations under the License. } } - .mx_MessageEditor_buttons { - display: flex; - flex-direction: row; - justify-content: flex-end; - padding: 5px; - position: absolute; - left: 0; - background: $header-panel-bg-color; - z-index: 100; - right: 0; - margin: 0 -110px 0 0; - padding-right: 147px; - - .mx_AccessibleButton { - margin-left: 5px; - padding: 5px 40px; - } - } - - .mx_MessageEditor_AutoCompleteWrapper { + .mx_BasicMessageComposer_AutoCompleteWrapper { position: relative; height: 0; } } - -.mx_EventTile_last .mx_MessageEditor_buttons { - position: static; - margin-right: -147px; -} diff --git a/res/css/views/rooms/_EditMessageComposer.scss b/res/css/views/rooms/_EditMessageComposer.scss new file mode 100644 index 0000000000..214bfc4a1a --- /dev/null +++ b/res/css/views/rooms/_EditMessageComposer.scss @@ -0,0 +1,63 @@ +/* +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_EditMessageComposer { + + padding: 3px; + // this is to try not make the text move but still have some + // padding around and in the editor. + // Actual values from fiddling around in inspector + margin: -7px -10px -5px -10px; + overflow: visible !important; // override mx_EventTile_content + + + .mx_BasicMessageComposer_input { + border-radius: 4px; + border: solid 1px $primary-hairline-color; + background-color: $primary-bg-color; + max-height: 200px; + padding: 3px 6px; + + &:focus { + border-color: $accent-color-50pct; + } + } + + .mx_EditMessageComposer_buttons { + display: flex; + flex-direction: row; + justify-content: flex-end; + padding: 5px; + position: absolute; + left: 0; + background: $header-panel-bg-color; + z-index: 100; + right: 0; + margin: 0 -110px 0 0; + padding-right: 147px; + + .mx_AccessibleButton { + margin-left: 5px; + padding: 5px 40px; + } + } +} + +.mx_EventTile_last .mx_EditMessageComposer_buttons { + position: static; + margin-right: -147px; +} diff --git a/res/css/views/rooms/_EntityTile.scss b/res/css/views/rooms/_EntityTile.scss index 44528a5624..2b6b31acb4 100644 --- a/res/css/views/rooms/_EntityTile.scss +++ b/res/css/views/rooms/_EntityTile.scss @@ -85,10 +85,6 @@ limitations under the License. overflow: hidden; } -.mx_EntityTile:not(.mx_EntityTile_noHover):hover .mx_EntityTile_name { - font-size: 13px; -} - .mx_EntityTile_ellipsis .mx_EntityTile_name { font-style: italic; color: $primary-fg-color; @@ -102,23 +98,24 @@ limitations under the License. .mx_EntityTile_unavailable .mx_EntityTile_avatar, .mx_EntityTile_unavailable .mx_EntityTile_name, .mx_EntityTile_offline_beenactive .mx_EntityTile_avatar, -.mx_EntityTile_offline_beenactive .mx_EntityTile_name -{ +.mx_EntityTile_offline_beenactive .mx_EntityTile_name { opacity: 0.5; } .mx_EntityTile_offline_neveractive .mx_EntityTile_avatar, -.mx_EntityTile_offline_neveractive .mx_EntityTile_name -{ +.mx_EntityTile_offline_neveractive .mx_EntityTile_name { opacity: 0.25; } .mx_EntityTile_unknown .mx_EntityTile_avatar, -.mx_EntityTile_unknown .mx_EntityTile_name -{ +.mx_EntityTile_unknown .mx_EntityTile_name { opacity: 0.25; } +.mx_EntityTile:not(.mx_EntityTile_noHover):hover .mx_EntityTile_name { + font-size: 13px; +} + .mx_EntityTile_subtext { font-size: 11px; opacity: 0.5; diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index cf3e5b7985..fafd34f8ca 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -56,13 +56,17 @@ limitations under the License. color: $primary-fg-color; font-size: 14px; display: inline-block; /* anti-zalgo, with overflow hidden */ - overflow-y: hidden; + overflow: hidden; cursor: pointer; padding-left: 65px; /* left gutter */ padding-bottom: 0px; padding-top: 0px; margin: 0px; line-height: 17px; + /* the next three lines, along with overflow hidden, truncate long display names */ + white-space: nowrap; + text-overflow: ellipsis; + max-width: calc(100% - 65px); } .mx_EventTile .mx_SenderProfile .mx_Flair { @@ -89,8 +93,6 @@ limitations under the License. display: block; visibility: hidden; white-space: nowrap; - color: $event-timestamp-color; - font-size: 10px; left: 0px; width: 46px; /* 8 + 30 (avatar) + 8 */ text-align: center; @@ -120,8 +122,29 @@ limitations under the License. /* HACK to override line-height which is already marked important elsewhere */ .mx_EventTile_bigEmoji.mx_EventTile_bigEmoji { - font-size: 48px ! important; - line-height: 52px ! important; + font-size: 48px !important; + line-height: 57px !important; +} + +.mx_MessagePanel_alwaysShowTimestamps .mx_MessageTimestamp { + visibility: visible; +} + +.mx_EventTile_selected > div > a > .mx_MessageTimestamp { + left: 3px; + width: auto; +} + +// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) +.mx_EventTile_last > div > a > .mx_MessageTimestamp, +.mx_EventTile:hover > div > a > .mx_MessageTimestamp, +.mx_EventTile.mx_EventTile_actionBarFocused > div > a > .mx_MessageTimestamp { + visibility: visible; +} + +.mx_EventTile:hover .mx_MessageActionBar, +.mx_EventTile.mx_EventTile_actionBarFocused .mx_MessageActionBar { + visibility: visible; } /* this is used for the tile for the event which is selected via the URL. @@ -133,9 +156,17 @@ limitations under the License. background-color: $event-selected-color; } +.mx_EventTile_highlight, +.mx_EventTile_highlight .markdown-body { + color: $event-highlight-fg-color; + + .mx_EventTile_line { + background-color: $event-highlight-bg-color; + } +} + .mx_EventTile:hover .mx_EventTile_line, -.mx_EventTile.mx_EventTile_actionBarFocused .mx_EventTile_line -{ +.mx_EventTile.mx_EventTile_actionBarFocused .mx_EventTile_line { background-color: $event-selected-color; } @@ -154,7 +185,7 @@ limitations under the License. } .mx_EventTile_encrypting { - color: $event-encrypting-color ! important; + color: $event-encrypting-color !important; } .mx_EventTile_sending { @@ -172,25 +203,30 @@ limitations under the License. .mx_EventTile_redacted .mx_EventTile_line .mx_UnknownBody, .mx_EventTile_redacted .mx_EventTile_reply .mx_UnknownBody { + --lozenge-color: $event-redacted-fg-color; + --lozenge-border-color: $event-redacted-border-color; display: block; - width: 100%; height: 22px; width: 250px; border-radius: 11px; - background: repeating-linear-gradient( - -45deg, - $event-redacted-fg-color, - $event-redacted-fg-color 3px, - transparent 3px, - transparent 6px - ); - box-shadow: 0px 0px 3px $event-redacted-border-color inset; + background: + repeating-linear-gradient( + -45deg, + var(--lozenge-color), + var(--lozenge-color) 3px, + transparent 3px, + transparent 6px + ); + box-shadow: 0px 0px 3px var(--lozenge-border-color) inset; } -.mx_EventTile_highlight, -.mx_EventTile_highlight .markdown-body - { - color: $warning-color; +.mx_EventTile_sending.mx_EventTile_redacted .mx_UnknownBody { + opacity: 0.4; +} + +div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { + --lozenge-color: $event-notsent-color; + --lozenge-border-color: $event-notsent-color; } .mx_EventTile_contextual { @@ -215,27 +251,6 @@ limitations under the License. text-decoration: none; } -// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) -.mx_EventTile_last > div > a > .mx_MessageTimestamp, -.mx_EventTile:hover > div > a > .mx_MessageTimestamp, -.mx_EventTile.mx_EventTile_actionBarFocused > div > a > .mx_MessageTimestamp { - visibility: visible; -} - -.mx_MessagePanel_alwaysShowTimestamps .mx_MessageTimestamp { - visibility: visible; -} - -.mx_EventTile_selected > div > a > .mx_MessageTimestamp { - left: 3px; - width: auto; -} - -.mx_EventTile:hover .mx_MessageActionBar, -.mx_EventTile.mx_EventTile_actionBarFocused .mx_MessageActionBar { - visibility: visible; -} - .mx_EventTile_readAvatars { position: relative; display: inline-block; @@ -243,6 +258,7 @@ limitations under the License. height: 14px; top: 29px; user-select: none; + z-index: 1; } .mx_EventTile_continuation .mx_EventTile_readAvatars, @@ -299,11 +315,6 @@ limitations under the License. filter: none; } -/* End to end encryption stuff */ -.mx_EventTile:hover .mx_EventTile_e2eIcon { - opacity: 1; -} - .mx_EventTile_e2eIcon { display: block; position: absolute; @@ -370,22 +381,31 @@ limitations under the License. padding-left: 5px; } +.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line { + padding-left: 78px; +} + .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line { padding-left: 60px; } -.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line, +.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line { + border-left: $e2e-verified-color 5px solid; +} + +.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line { + border-left: $e2e-unverified-color 5px solid; +} + .mx_EventTile:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line { padding-left: 78px; } -.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line { - border-left: $e2e-verified-color 5px solid; -} -.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line { - border-left: $e2e-unverified-color 5px solid; +/* End to end encryption stuff */ +.mx_EventTile:hover .mx_EventTile_e2eIcon { + opacity: 1; } // Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) @@ -395,12 +415,6 @@ limitations under the License. width: auto; } -/* -.mx_EventTile_verified .mx_EventTile_e2eIcon { - display: none; -} -*/ - // Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > .mx_EventTile_e2eIcon, .mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon { @@ -414,39 +428,45 @@ limitations under the License. color: $roomtopic-color; display: inline-block; margin-left: 9px; + cursor: pointer; } /* Various markdown overrides */ +.mx_EventTile_body pre { + border: 1px solid transparent; +} + .mx_EventTile_content .markdown-body { - font-family: inherit ! important; - white-space: normal ! important; - line-height: inherit ! important; + font-family: inherit !important; + white-space: normal !important; + line-height: inherit !important; color: inherit; // inherit the colour from the dark or light theme by default (but not for code blocks) font-size: 14px; -} -/* have to use overlay rather than auto otherwise Linux and Windows - Chrome gets very confused about vertical spacing: - https://github.com/vector-im/vector-web/issues/754 -*/ -.mx_EventTile_content .markdown-body pre { - overflow-x: overlay; - overflow-y: visible; - max-height: 30vh; -} - -.mx_EventTile_content .markdown-body code { - // deliberate constants as we're behind an invert filter - background-color: #f8f8f8; -} - -.mx_EventTile_content .markdown-body { pre, code { - font-family: $monospace-font-family ! important; + font-family: $monospace-font-family !important; // deliberate constants as we're behind an invert filter color: #333; } + + pre { + // have to use overlay rather than auto otherwise Linux and Windows + // Chrome gets very confused about vertical spacing: + // https://github.com/vector-im/vector-web/issues/754 + overflow-x: overlay; + overflow-y: visible; + max-height: 30vh; + } + + code { + // deliberate constants as we're behind an invert filter + background-color: #f8f8f8; + } +} + +.mx_EventTile:hover .mx_EventTile_body pre { + border: 1px solid #e5e5e5; // deliberate constant as we're behind an invert filter } .mx_EventTile_pre_container { @@ -467,17 +487,7 @@ limitations under the License. background-image: url($copy-button-url); } -.mx_EventTile_body pre { - border: 1px solid transparent; -} - -.mx_EventTile:hover .mx_EventTile_body pre -{ - border: 1px solid #e5e5e5; // deliberate constant as we're behind an invert filter -} - -.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton -{ +.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton { visibility: visible; } @@ -486,19 +496,17 @@ limitations under the License. .mx_EventTile_content .markdown-body h3, .mx_EventTile_content .markdown-body h4, .mx_EventTile_content .markdown-body h5, -.mx_EventTile_content .markdown-body h6 -{ - font-family: inherit ! important; +.mx_EventTile_content .markdown-body h6 { + font-family: inherit !important; color: inherit; } /* Make h1 and h2 the same size as h3. */ .mx_EventTile_content .markdown-body h1, -.mx_EventTile_content .markdown-body h2 -{ +.mx_EventTile_content .markdown-body h2 { font-size: 1.5em; - border-bottom: none ! important; // override GFM + border-bottom: none !important; // override GFM } .mx_EventTile_content .markdown-body a { @@ -507,7 +515,7 @@ limitations under the License. } .mx_EventTile_content .markdown-body .hljs { - display: inline ! important; + display: inline !important; } /* @@ -524,6 +532,9 @@ limitations under the License. /* end of overrides */ +/* Ordering this block by specificity would require breaking it up into several + chunks, which seems like it would be more confusing to read. */ +/* stylelint-disable no-descending-specificity */ .mx_MatrixChat_useCompactLayout { .mx_EventTile { padding-top: 4px; @@ -596,8 +607,9 @@ limitations under the License. } .mx_EventTile_content .markdown-body { - p, ul, ol, dl, blockquote, pre, table { - margin-bottom: 4px; // 1/4 of the non-compact margin-bottom - } + p, ul, ol, dl, blockquote, pre, table { + margin-bottom: 4px; // 1/4 of the non-compact margin-bottom + } } } +/* stylelint-enable no-descending-specificity */ diff --git a/res/css/views/rooms/_JumpToBottomButton.scss b/res/css/views/rooms/_JumpToBottomButton.scss index 968139671f..7f458092fb 100644 --- a/res/css/views/rooms/_JumpToBottomButton.scss +++ b/res/css/views/rooms/_JumpToBottomButton.scss @@ -55,7 +55,7 @@ limitations under the License. cursor: pointer; } -.mx_JumpToBottomButton_scrollDown:before { +.mx_JumpToBottomButton_scrollDown::before { content: ""; position: absolute; top: 0; diff --git a/res/css/views/rooms/_MemberDeviceInfo.scss b/res/css/views/rooms/_MemberDeviceInfo.scss index 3be6a0f7b4..951d1945b1 100644 --- a/res/css/views/rooms/_MemberDeviceInfo.scss +++ b/res/css/views/rooms/_MemberDeviceInfo.scss @@ -57,6 +57,7 @@ limitations under the License. } .mx_MemberDeviceInfo_deviceId { + word-break: break-word; font-size: 13px; } diff --git a/res/css/views/rooms/_MemberInfo.scss b/res/css/views/rooms/_MemberInfo.scss index c3b3ca2f7d..e3f746e9d3 100644 --- a/res/css/views/rooms/_MemberInfo.scss +++ b/res/css/views/rooms/_MemberInfo.scss @@ -43,6 +43,8 @@ limitations under the License. .mx_MemberInfo_name h2 { flex: 1; + overflow-x: auto; + max-height: 50px; } .mx_MemberInfo h2 { @@ -80,9 +82,6 @@ limitations under the License. display: block; } -.mx_MemberInfo_avatar .mx_BaseAvatar { -} - .mx_MemberInfo_avatar .mx_BaseAvatar.mx_BaseAvatar_image { cursor: zoom-in; } @@ -124,7 +123,7 @@ limitations under the License. } .mx_MemberInfo_createRoom_label { - width: initial ! important; + width: initial !important; cursor: pointer; } diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss index 0b9c7e2368..6e4465583c 100644 --- a/res/css/views/rooms/_MemberList.scss +++ b/res/css/views/rooms/_MemberList.scss @@ -88,7 +88,7 @@ limitations under the License. } .mx_MemberList_invite.mx_AccessibleButton_disabled { - background-color: $greyed-fg-color;; + background-color: $greyed-fg-color; cursor: not-allowed; } @@ -97,5 +97,4 @@ limitations under the License. background-repeat: no-repeat; background-position: center left; padding-left: 25px; - } diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index 708c29bb3e..5b4a9b764b 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -122,16 +122,15 @@ limitations under the License. // FIXME: rather unpleasant hack to get rid of

margins. // really we should be mixing in markdown-body from gfm.css instead .mx_MessageComposer_editor > :first-child { - margin-top: 0 ! important; + margin-top: 0 !important; } .mx_MessageComposer_editor > :last-child { - margin-bottom: 0 ! important; + margin-bottom: 0 !important; } -@keyframes visualbell -{ - from { background-color: #faa } - to { background-color: $primary-bg-color } +@keyframes visualbell { + from { background-color: $visual-bell-bg-color; } + to { background-color: $primary-bg-color; } } .mx_MessageComposer_input_error { diff --git a/res/css/views/rooms/_PinnedEventTile.scss b/res/css/views/rooms/_PinnedEventTile.scss index f7417272b6..030a76674a 100644 --- a/res/css/views/rooms/_PinnedEventTile.scss +++ b/res/css/views/rooms/_PinnedEventTile.scss @@ -44,6 +44,12 @@ limitations under the License. margin-right: 10px; } +.mx_PinnedEventTile_actions { + float: right; + margin-right: 10px; + display: none; +} + .mx_PinnedEventTile:hover .mx_PinnedEventTile_timestamp { display: inline-block; } @@ -52,12 +58,6 @@ limitations under the License. display: block; } -.mx_PinnedEventTile_actions { - float: right; - margin-right: 10px; - display: none; -} - .mx_PinnedEventTile_unpinButton { display: inline-block; cursor: pointer; @@ -74,4 +74,4 @@ limitations under the License. position: relative; top: 0; left: 0; -} \ No newline at end of file +} diff --git a/res/css/views/rooms/_PresenceLabel.scss b/res/css/views/rooms/_PresenceLabel.scss index 682c849cee..26ed1aa6a3 100644 --- a/res/css/views/rooms/_PresenceLabel.scss +++ b/res/css/views/rooms/_PresenceLabel.scss @@ -17,4 +17,4 @@ limitations under the License. .mx_PresenceLabel { font-size: 11px; opacity: 0.5; -} \ No newline at end of file +} diff --git a/res/css/views/rooms/_ReplyPreview.scss b/res/css/views/rooms/_ReplyPreview.scss index 5bf4adff27..4dc4cb2c40 100644 --- a/res/css/views/rooms/_ReplyPreview.scss +++ b/res/css/views/rooms/_ReplyPreview.scss @@ -24,7 +24,7 @@ limitations under the License. border-bottom: none; border-radius: 4px 4px 0 0; max-height: 50vh; - overflow: auto + overflow: auto; } .mx_ReplyPreview_section { diff --git a/res/css/views/rooms/_RoomBreadcrumbs.scss b/res/css/views/rooms/_RoomBreadcrumbs.scss index 6c3eb0420a..13fcbf2529 100644 --- a/res/css/views/rooms/_RoomBreadcrumbs.scss +++ b/res/css/views/rooms/_RoomBreadcrumbs.scss @@ -78,6 +78,14 @@ limitations under the License. display: none; } + .mx_IndicatorScrollbar_leftOverflowIndicator { + background: linear-gradient(to left, $panel-gradient); + } + + .mx_IndicatorScrollbar_rightOverflowIndicator { + background: linear-gradient(to right, $panel-gradient); + } + &.mx_IndicatorScrollbar_leftOverflow .mx_IndicatorScrollbar_leftOverflowIndicator, &.mx_IndicatorScrollbar_rightOverflow .mx_IndicatorScrollbar_rightOverflowIndicator { position: absolute; @@ -88,13 +96,4 @@ limitations under the License. pointer-events: none; z-index: 100; } - - .mx_IndicatorScrollbar_leftOverflowIndicator { - background: linear-gradient(to left, $panel-gradient); - } - - .mx_IndicatorScrollbar_rightOverflowIndicator { - background: linear-gradient(to right, $panel-gradient); - } } - diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index e7589f0e90..2ee991cac7 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -22,7 +22,6 @@ limitations under the License. .mx_RoomHeader_wrapper { margin: auto; height: 52px; - align-items: center; display: flex; align-items: center; min-width: 0; @@ -107,7 +106,7 @@ limitations under the License. } .mx_RoomHeader_settingsHint { - color: $settings-grey-fg-color ! important; + color: $settings-grey-fg-color !important; } .mx_RoomHeader_searchStatus { @@ -134,17 +133,17 @@ limitations under the License. } .mx_RoomHeader_placeholder { - color: $settings-grey-fg-color ! important; + color: $settings-grey-fg-color !important; } .mx_RoomHeader_editable { - border-bottom: 1px solid $strong-input-border-color ! important; + border-bottom: 1px solid $strong-input-border-color !important; min-width: 150px; cursor: text; } .mx_RoomHeader_editable:focus { - border-bottom: 1px solid $accent-color ! important; + border-bottom: 1px solid $accent-color !important; outline: none; box-shadow: none; } diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss index 886f10dc4c..b51d720e4d 100644 --- a/res/css/views/rooms/_RoomList.scss +++ b/res/css/views/rooms/_RoomList.scss @@ -32,7 +32,7 @@ limitations under the License. } /* hide resize handles next to collapsed / empty sublists */ -.mx_RoomList .mx_RoomSubList:not(.mx_RoomSubList_nonEmpty) + .mx_ResizeHandle { +.mx_RoomList .mx_RoomSubList:not(.mx_RoomSubList_nonEmpty) + .mx_ResizeHandle { display: none; } diff --git a/res/css/views/rooms/_RoomPreviewBar.scss b/res/css/views/rooms/_RoomPreviewBar.scss index ea3b787971..c7d03e3523 100644 --- a/res/css/views/rooms/_RoomPreviewBar.scss +++ b/res/css/views/rooms/_RoomPreviewBar.scss @@ -39,6 +39,16 @@ limitations under the License. margin: 10px 10px 10px 0; flex: 0 0 auto; } + + .mx_RoomPreviewBar_footer { + font-size: 12px; + line-height: 20px; + + .mx_Spinner { + vertical-align: middle; + display: inline-block; + } + } } .mx_RoomPreviewBar_dark { @@ -70,7 +80,7 @@ limitations under the License. flex-direction: row; padding: 3px 8px; - &>* { + & > * { margin-left: 12px; } } @@ -81,7 +91,7 @@ limitations under the License. display: flex; flex-direction: column; - &>* { + & > * { margin: 4px; } } @@ -99,7 +109,7 @@ limitations under the License. .mx_RoomPreviewBar_message { flex-direction: column; - &>* { + & > * { margin: 5px 0 20px 0; } } @@ -110,7 +120,7 @@ limitations under the License. padding: 7px 50px;//extra wide } - &>* { + & > * { margin-top: 12px; } } diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index a1fc9bdca1..25330973b6 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -25,6 +25,11 @@ limitations under the License. position: relative; } +.mx_RoomTile:focus { + filter: none !important; + background-color: $roomtile-focused-bg-color; +} + .mx_RoomTile_menuButton { display: none; flex: 0 0 16px; @@ -34,13 +39,6 @@ limitations under the License. background-position: center; } -// toggle menuButton and badge on hover/menu displayed -.mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:hover, .mx_RoomTile_menuDisplayed { - .mx_RoomTile_menuButton { - display: block; - } -} - .mx_RoomTile_tooltip { display: inline-block; position: relative; @@ -48,7 +46,6 @@ limitations under the License. left: -12px; } - .mx_RoomTile_nameContainer { display: flex; align-items: center; @@ -109,9 +106,18 @@ limitations under the License. text-overflow: ellipsis; } +.mx_RoomTile_badge { + flex: 0 1 content; + border-radius: 0.8em; + padding: 0 0.4em; + color: $accent-fg-color; + font-weight: 600; + font-size: 12px; +} + .collapsed { .mx_RoomTile { - margin: 0 2px; + margin: 0 6px; padding: 0 2px; position: relative; justify-content: center; @@ -135,13 +141,12 @@ limitations under the License. } } -.mx_RoomTile_badge { - flex: 0 1 content; - border-radius: 0.8em; - padding: 0 0.4em; - color: $accent-fg-color; - font-weight: 600; - font-size: 12px; +// toggle menuButton and badge on hover/menu displayed +.mx_RoomTile_menuDisplayed, +.mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:hover { + .mx_RoomTile_menuButton { + display: block; + } } .mx_RoomTile_unreadNotify .mx_RoomTile_badge, @@ -150,8 +155,7 @@ limitations under the License. } .mx_RoomTile_highlight .mx_RoomTile_badge, -.mx_RoomTile_badge.mx_RoomTile_badgeRed -{ +.mx_RoomTile_badge.mx_RoomTile_badgeRed { background-color: $warning-color; } @@ -176,11 +180,6 @@ limitations under the License. transform: scale(1.05, 1.05); } -.mx_RoomTile:focus { - filter: none !important; - background-color: $roomtile-focused-bg-color; -} - .mx_RoomTile_arrow { position: absolute; right: 0px; diff --git a/res/css/views/rooms/_RoomUpgradeWarningBar.scss b/res/css/views/rooms/_RoomUpgradeWarningBar.scss index fe81d3801a..1c477cedfe 100644 --- a/res/css/views/rooms/_RoomUpgradeWarningBar.scss +++ b/res/css/views/rooms/_RoomUpgradeWarningBar.scss @@ -15,17 +15,22 @@ limitations under the License. */ .mx_RoomUpgradeWarningBar { + max-height: 235px; + background-color: $preview-bar-bg-color; + padding-left: 20px; + padding-right: 20px; + overflow: scroll; +} + +.mx_RoomUpgradeWarningBar_wrapped { + width: 100%; + height: 100%; + display: flex; text-align: center; - height: 235px; - background-color: $event-selected-color; align-items: center; flex-direction: column; justify-content: center; - display: flex; - background-color: $preview-bar-bg-color; -webkit-align-items: center; - padding-left: 20px; - padding-right: 20px; } .mx_RoomUpgradeWarningBar_header { 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/rooms/_TopUnreadMessagesBar.scss b/res/css/views/rooms/_TopUnreadMessagesBar.scss index a4b7a6aa51..77f19dac1c 100644 --- a/res/css/views/rooms/_TopUnreadMessagesBar.scss +++ b/res/css/views/rooms/_TopUnreadMessagesBar.scss @@ -24,7 +24,7 @@ limitations under the License. width: 38px; } -.mx_TopUnreadMessagesBar:after { +.mx_TopUnreadMessagesBar::after { content: "·"; position: absolute; top: -8px; @@ -49,7 +49,7 @@ limitations under the License. cursor: pointer; } -.mx_TopUnreadMessagesBar_scrollUp:before { +.mx_TopUnreadMessagesBar_scrollUp::before { content: ""; position: absolute; width: 38px; diff --git a/res/css/views/settings/_DevicesPanel.scss b/res/css/views/settings/_DevicesPanel.scss index e4856531d9..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 { @@ -48,4 +53,4 @@ limitations under the License. .mx_DevicesPanel_myDevice { font-weight: bold; -} \ No newline at end of file +} diff --git a/res/css/views/settings/_EmailAddresses.scss b/res/css/views/settings/_EmailAddresses.scss index eef804a33b..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. @@ -15,6 +16,8 @@ limitations under the License. */ .mx_ExistingEmailAddress { + display: flex; + align-items: center; margin-bottom: 5px; } @@ -24,20 +27,12 @@ limitations under the License. vertical-align: middle; } -.mx_ExistingEmailAddress_email { - vertical-align: middle; -} - +.mx_ExistingEmailAddress_email, .mx_ExistingEmailAddress_promptText { + flex: 1; margin-right: 10px; } .mx_ExistingEmailAddress_confirmBtn { - margin-right: 5px; -} - -.mx_EmailAddresses_new .mx_Field input { - // Use 100% of the space available for the input, but don't let the 10px - // padding on either side of the input to push it out of alignment. - width: calc(100% - 20px); + margin-left: 5px; } diff --git a/res/css/views/settings/_IntegrationsManager.scss b/res/css/views/settings/_IntegrationsManager.scss index 93ee0e20fe..8b51eb272e 100644 --- a/res/css/views/settings/_IntegrationsManager.scss +++ b/res/css/views/settings/_IntegrationsManager.scss @@ -29,3 +29,16 @@ limitations under the License. width: 100%; height: 100%; } + +.mx_IntegrationsManager_loading h3 { + text-align: center; +} + +.mx_IntegrationsManager_error { + text-align: center; + padding-top: 20px; +} + +.mx_IntegrationsManager_error h3 { + color: $warning-color; +} diff --git a/res/css/views/settings/_Notifications.scss b/res/css/views/settings/_Notifications.scss index d6c0b5dbeb..e6d09b9a2a 100644 --- a/res/css/views/settings/_Notifications.scss +++ b/res/css/views/settings/_Notifications.scss @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_UserNotifSettings_tableRow -{ +.mx_UserNotifSettings_tableRow { display: table-row; } @@ -26,8 +25,7 @@ limitations under the License. width: 16px; } -.mx_UserNotifSettings_labelCell -{ +.mx_UserNotifSettings_labelCell { padding-bottom: 8px; width: 400px; display: table-cell; @@ -71,3 +69,26 @@ limitations under the License. .mx_UserNotifSettings_notifTable .mx_Spinner { position: absolute; } + +.mx_NotificationSound_soundUpload { + display: none; +} + +.mx_NotificationSound_browse { + color: $accent-color; + border: 1px solid $accent-color; + background-color: transparent; +} + +.mx_NotificationSound_save { + margin-left: 5px; + color: white; + background-color: $accent-color; +} + +.mx_NotificationSound_resetSound { + margin-top: 5px; + color: white; + border: $warning-color; + background-color: $warning-color; +} diff --git a/res/css/views/settings/_PhoneNumbers.scss b/res/css/views/settings/_PhoneNumbers.scss index 2f54babd6f..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. @@ -15,6 +16,8 @@ limitations under the License. */ .mx_ExistingPhoneNumber { + display: flex; + align-items: center; margin-bottom: 5px; } @@ -24,22 +27,23 @@ limitations under the License. vertical-align: middle; } -.mx_ExistingPhoneNumber_address { - vertical-align: middle; -} - +.mx_ExistingPhoneNumber_address, .mx_ExistingPhoneNumber_promptText { + flex: 1; margin-right: 10px; } .mx_ExistingPhoneNumber_confirmBtn { - margin-right: 5px; + margin-left: 5px; } -.mx_PhoneNumbers_new .mx_Field input { - // Use 100% of the space available for the input, but don't let the 10px - // padding on either side of the input to push it out of alignment. - width: calc(100% - 20px); +.mx_ExistingPhoneNumber_verification { + display: inline-flex; + align-items: center; + + .mx_Field { + margin: 0 0 0 1em; + } } .mx_PhoneNumbers_input { diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss index b2e449ac34..432b713c1b 100644 --- a/res/css/views/settings/_ProfileSettings.scss +++ b/res/css/views/settings/_ProfileSettings.scss @@ -22,11 +22,6 @@ limitations under the License. flex-grow: 1; } -.mx_ProfileSettings_controls .mx_Field #profileDisplayName, -.mx_ProfileSettings_controls .mx_Field #profileTopic { - width: calc(100% - 20px); // subtract 10px padding on left and right -} - .mx_ProfileSettings_controls .mx_Field #profileTopic { height: 4em; } @@ -48,7 +43,6 @@ limitations under the License. height: 88px; margin-left: 13px; position: relative; - cursor: pointer; } .mx_ProfileSettings_avatar > * { @@ -76,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) { @@ -110,7 +105,7 @@ limitations under the License. margin: auto; } -.mx_ProfileSettings_avatarOverlayImg:before { +.mx_ProfileSettings_avatarOverlayImg::before { background-color: $settings-profile-overlay-placeholder-fg-color; mask: url("$(res)/img/feather-customised/upload.svg"); mask-repeat: no-repeat; @@ -124,7 +119,7 @@ limitations under the License. right: 0; } -.mx_ProfileSettings_avatar:hover .mx_ProfileSettings_avatarOverlayImg:before { +.mx_ProfileSettings_avatar:hover .mx_ProfileSettings_avatarOverlayImg::before { background-color: $settings-profile-overlay-fg-color !important; } diff --git a/res/css/views/messages/_ReactionDimension.scss b/res/css/views/settings/_SetIdServer.scss similarity index 75% rename from res/css/views/messages/_ReactionDimension.scss rename to res/css/views/settings/_SetIdServer.scss index 9a891d05cf..98c64b7218 100644 --- a/res/css/views/messages/_ReactionDimension.scss +++ b/res/css/views/settings/_SetIdServer.scss @@ -1,5 +1,5 @@ /* -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,12 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ReactionDimension { - width: 42px; - display: flex; - justify-content: space-evenly; +.mx_SetIdServer .mx_Field_input { + @mixin mx_Settings_fullWidthField; } -.mx_ReactionDimension_disabled { - opacity: 0.4; +.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 def28bfbd2..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; -} \ No newline at end of file +} diff --git a/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.scss b/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.scss index 91d7ed2c7d..af55820d66 100644 --- a/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.scss +++ b/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.scss @@ -17,7 +17,3 @@ limitations under the License. .mx_GeneralRoomSettingsTab_profileSection { margin-top: 10px; } - -.mx_GeneralRoomSettingsTab .mx_AliasSettings .mx_Field select { - width: 100%; -} diff --git a/res/css/views/settings/tabs/room/_RolesRoomSettingsTab.scss b/res/css/views/settings/tabs/room/_RolesRoomSettingsTab.scss index 8bfc792dc3..5d0a8ed142 100644 --- a/res/css/views/settings/tabs/room/_RolesRoomSettingsTab.scss +++ b/res/css/views/settings/tabs/room/_RolesRoomSettingsTab.scss @@ -21,4 +21,4 @@ limitations under the License. .mx_RolesRoomSettingsTab_unbanBtn { margin-right: 10px; margin-bottom: 5px; -} \ No newline at end of file +} diff --git a/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss b/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss index dfd046e672..b5a57dfefb 100644 --- a/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss +++ b/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss @@ -31,4 +31,4 @@ limitations under the License. .mx_SecurityRoomSettingsTab_encryptionSection { margin-bottom: 25px; -} \ No newline at end of file +} diff --git a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss index bec013674a..62d230e752 100644 --- a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss @@ -14,33 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_GeneralUserSettingsTab_changePassword, -.mx_GeneralUserSettingsTab_themeSection { - display: block; -} - .mx_GeneralUserSettingsTab_changePassword .mx_Field, .mx_GeneralUserSettingsTab_themeSection .mx_Field { - display: block; - margin-right: 100px; // Align with the other fields on the page -} - -.mx_GeneralUserSettingsTab_changePassword .mx_Field input { - display: block; - width: calc(100% - 20px); // subtract 10px padding on left and right + @mixin mx_Settings_fullWidthField; } .mx_GeneralUserSettingsTab_changePassword .mx_Field:first-child { margin-top: 0; } -.mx_GeneralUserSettingsTab_themeSection .mx_Field select { - display: block; - width: 100%; +.mx_GeneralUserSettingsTab_accountSection .mx_EmailAddresses, +.mx_GeneralUserSettingsTab_accountSection .mx_PhoneNumbers, +.mx_GeneralUserSettingsTab_discovery .mx_ExistingEmailAddress, +.mx_GeneralUserSettingsTab_discovery .mx_ExistingPhoneNumber, +.mx_GeneralUserSettingsTab_languageInput { + @mixin mx_Settings_fullWidthField; } -.mx_GeneralUserSettingsTab_accountSection > .mx_EmailAddresses, -.mx_GeneralUserSettingsTab_accountSection > .mx_PhoneNumbers, -.mx_GeneralUserSettingsTab_languageInput { - margin-right: 100px; // Align with the other fields on the page -} \ No newline at end of file +.mx_GeneralUserSettingsTab_warningIcon { + vertical-align: middle; +} diff --git a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss index fa0d0edeb7..109edfff81 100644 --- a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss @@ -21,4 +21,4 @@ limitations under the License. .mx_HelpUserSettingsTab span.mx_AccessibleButton { word-break: break-word; -} \ No newline at end of file +} diff --git a/res/css/views/settings/tabs/user/_NotificationUserSettingsTab.scss b/res/css/views/settings/tabs/user/_NotificationUserSettingsTab.scss index 3cebd2958e..b57c46a1d9 100644 --- a/res/css/views/settings/tabs/user/_NotificationUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_NotificationUserSettingsTab.scss @@ -16,4 +16,4 @@ limitations under the License. .mx_NotificationUserSettingsTab .mx_SettingsTab_heading { margin-bottom: 10px; // Give some spacing between the title and the first elements -} \ No newline at end of file +} diff --git a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss index f447221b7a..d003e175d9 100644 --- a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss @@ -15,13 +15,5 @@ limitations under the License. */ .mx_PreferencesUserSettingsTab .mx_Field { - margin-right: 100px; // Align with the rest of the controls -} - -.mx_PreferencesUserSettingsTab .mx_Field input { - display: block; - - // Subtract 10px padding on left and right - // This is to keep the input aligned with the rest of the tab's controls. - width: calc(100% - 20px); + @mixin mx_Settings_fullWidthField; } diff --git a/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.scss b/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.scss index 1c0a4b5864..b5a6693006 100644 --- a/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_SecurityUserSettingsTab.scss @@ -54,4 +54,4 @@ limitations under the License. .mx_SecurityUserSettingsTab_ignoredUser .mx_AccessibleButton { margin-right: 10px; -} \ No newline at end of file +} diff --git a/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss index f5dba9831e..69d57bdba1 100644 --- a/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss @@ -14,13 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_VoiceUserSettingsTab .mx_Field select { - width: 100%; - max-width: 100%; -} - .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/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index deb89a837c..b01fbf8c66 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -22,4 +22,4 @@ limitations under the License. padding: 6px; font-weight: bold; font-size: 13px; -} \ No newline at end of file +} diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 index 70f9b03c4d..593d7c8f5c 100644 Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 and b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 differ diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 new file mode 100644 index 0000000000..277324851f Binary files /dev/null and b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 differ diff --git a/res/img/react.svg b/res/img/react.svg new file mode 100644 index 0000000000..dd23c41c2c --- /dev/null +++ b/res/img/react.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index bdccf71540..f54d25ab29 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -121,6 +121,9 @@ $event-sending-color: $text-secondary-color; $event-redacted-fg-color: #606060; $event-redacted-border-color: #000000; +$event-highlight-fg-color: $warning-color; +$event-highlight-bg-color: #25271F; + // event timestamp $event-timestamp-color: $text-secondary-color; @@ -140,6 +143,10 @@ $button-danger-fg-color: #ffffff; $button-danger-bg-color: $notice-primary-color; $button-danger-disabled-fg-color: #ffffff; $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; @@ -160,6 +167,9 @@ $reaction-row-button-selected-border-color: $accent-color; $tooltip-timeline-bg-color: $tagpanel-bg-color; $tooltip-timeline-fg-color: #ffffff; +$interactive-tooltip-bg-color: $base-color; +$interactive-tooltip-fg-color: #ffffff; + // ***** Mixins! ***** @define-mixin mx_DialogButton { diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 712d905b43..be46367fbb 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -28,15 +28,17 @@ $focus-bg-color: #dddddd; // button UI (white-on-green in light skin) $accent-fg-color: #ffffff; -$accent-color-50pct: #92caad; +$accent-color-50pct: rgba(3, 179, 129, 0.5); //#03b381 in rgb +$accent-color-darker: #92caad; $accent-color-alt: #238CF5; $selection-fg-color: $primary-bg-color; $focus-brightness: 105%; -// red warning colour -$warning-color: $notice-primary-color; +// warning colours +$warning-color: $notice-primary-color; // red +$orange-warning-color: #ff8d13; // used for true warnings // background colour for warnings $warning-bg-color: #DF2A8B; $info-bg-color: #2A9EDF; @@ -194,11 +196,19 @@ $widget-menu-bar-bg-color: $secondary-accent-color; // ******************** +// both $event-highlight-bg-color and $room-warning-bg-color share this value, +// so to not make their order dependent on who depends on who, have a shared value +// defined before both +$yellow-background: #fff8e3; + // event tile lifecycle $event-encrypting-color: #abddbc; $event-sending-color: #ddd; $event-notsent-color: #f44; +$event-highlight-fg-color: $warning-color; +$event-highlight-bg-color: $yellow-background; + // event redaction $event-redacted-fg-color: #e2e2e2; $event-redacted-border-color: #cccccc; @@ -234,6 +244,10 @@ $button-danger-fg-color: #ffffff; $button-danger-bg-color: $notice-primary-color; $button-danger-disabled-fg-color: #ffffff; $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; @@ -242,7 +256,7 @@ $togglesw-ball-color: #fff; $progressbar-color: #000; -$room-warning-bg-color: #fff8e3; +$room-warning-bg-color: $yellow-background; $memberstatus-placeholder-color: $roomtile-name-color; @@ -271,6 +285,9 @@ $reaction-row-button-selected-border-color: $accent-color; $tooltip-timeline-bg-color: $tagpanel-bg-color; $tooltip-timeline-fg-color: #ffffff; +$interactive-tooltip-bg-color: #27303a; +$interactive-tooltip-fg-color: #ffffff; + // ***** Mixins! ***** @define-mixin mx_DialogButton { diff --git a/scripts/compare-file.js b/scripts/compare-file.js new file mode 100644 index 0000000000..f53275ebfa --- /dev/null +++ b/scripts/compare-file.js @@ -0,0 +1,10 @@ +const fs = require("fs"); + +if (process.argv.length < 4) throw new Error("Missing source and target file arguments"); + +const sourceFile = fs.readFileSync(process.argv[2], 'utf8'); +const targetFile = fs.readFileSync(process.argv[3], 'utf8'); + +if (sourceFile !== targetFile) { + throw new Error("Files do not match"); +} diff --git a/src/AddThreepid.js b/src/AddThreepid.js index adeaf84a69..8bd3099002 100644 --- a/src/AddThreepid.js +++ b/src/AddThreepid.js @@ -17,6 +17,7 @@ limitations under the License. import MatrixClientPeg from './MatrixClientPeg'; import { _t } from './languageHandler'; +import IdentityAuthClient from './IdentityAuthClient'; /** * Allows a user to add a third party identifier to their homeserver and, @@ -103,24 +104,29 @@ export default class AddThreepid { /** * Takes a phone number verification code as entered by the user and validates * it with the ID server, then if successful, adds the phone number. - * @param {string} token phone number verification code as entered by the user + * @param {string} msisdnToken phone number verification code as entered by the user * @return {Promise} Resolves if the phone number was added. Rejects with an object * with a "message" property which contains a human-readable message detailing why * the request failed. */ - haveMsisdnToken(token) { - return MatrixClientPeg.get().submitMsisdnToken( - this.sessionId, this.clientSecret, token, - ).then((result) => { - if (result.errcode) { - throw result; - } - const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1]; - return MatrixClientPeg.get().addThreePid({ - sid: this.sessionId, - client_secret: this.clientSecret, - id_server: identityServerDomain, - }, this.bind); - }); + async haveMsisdnToken(msisdnToken) { + const authClient = new IdentityAuthClient(); + const identityAccessToken = await authClient.getAccessToken(); + const result = await MatrixClientPeg.get().submitMsisdnToken( + this.sessionId, + this.clientSecret, + msisdnToken, + identityAccessToken, + ); + if (result.errcode) { + throw result; + } + + const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1]; + return MatrixClientPeg.get().addThreePid({ + sid: this.sessionId, + client_secret: this.clientSecret, + id_server: identityServerDomain, + }, this.bind); } } diff --git a/src/BasePlatform.js b/src/BasePlatform.js index 54310d1849..a97c14bf90 100644 --- a/src/BasePlatform.js +++ b/src/BasePlatform.js @@ -36,6 +36,7 @@ export default class BasePlatform { _onAction(payload: Object) { switch (payload.action) { + case 'on_client_not_viable': case 'on_logged_out': this.setNotificationCount(0); break; @@ -127,6 +128,18 @@ export default class BasePlatform { throw new Error("Unimplemented"); } + supportsAutoHideMenuBar(): boolean { + return false; + } + + async getAutoHideMenuBarEnabled(): boolean { + return false; + } + + async setAutoHideMenuBarEnabled(enabled: boolean): void { + throw new Error("Unimplemented"); + } + supportsMinimizeToTray(): boolean { return false; } diff --git a/src/CallHandler.js b/src/CallHandler.js index e47209eebe..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); @@ -344,18 +382,24 @@ function _onAction(payload) { } async function _startCallApp(roomId, type) { - // check for a working intgrations manager. Technically we could put + // check for a working integrations manager. Technically we could put // 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/CallMediaHandler.js b/src/CallMediaHandler.js index 9a1c9d70b8..a0364f798a 100644 --- a/src/CallMediaHandler.js +++ b/src/CallMediaHandler.js @@ -18,6 +18,11 @@ import * as Matrix from 'matrix-js-sdk'; import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; export default { + hasAnyLabeledDevices: async function() { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.some(d => !!d.label); + }, + getDevices: function() { // Only needed for Electron atm, though should work in modern browsers // once permission has been granted to the webapp @@ -26,8 +31,6 @@ export default { const audioinput = []; const videoinput = []; - if (devices.some((device) => !device.label)) return false; - devices.forEach((device) => { switch (device.kind) { case 'audiooutput': audiooutput.push(device); break; diff --git a/src/ContentMessages.js b/src/ContentMessages.js index ee3e8f1390..2d58622db8 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -425,19 +425,25 @@ export default class ContentMessages { } const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); + let uploadAll = false; for (let i = 0; i < okFiles.length; ++i) { const file = okFiles[i]; - const shouldContinue = await new Promise((resolve) => { - Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, { - file, - currentIndex: i, - totalFiles: okFiles.length, - onFinished: (shouldContinue) => { - resolve(shouldContinue); - }, + if (!uploadAll) { + const shouldContinue = await new Promise((resolve) => { + Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, { + file, + currentIndex: i, + totalFiles: okFiles.length, + onFinished: (shouldContinue, shouldUploadAll) => { + if (shouldUploadAll) { + uploadAll = true; + } + resolve(shouldContinue); + }, + }); }); - }); - if (!shouldContinue) break; + if (!shouldContinue) break; + } this._sendContentToRoom(file, roomId, matrixClient); } } diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index 61c51d4a20..8915c1412f 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -1,6 +1,7 @@ /* Copyright 2018 New Vector Ltd Copyright 2019 Travis Ralston +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. @@ -17,9 +18,12 @@ limitations under the License. import URL from 'url'; import dis from './dispatcher'; -import IntegrationManager from './IntegrationManager'; import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; +import MatrixClientPeg from "./MatrixClientPeg"; +import RoomViewStore from "./stores/RoomViewStore"; +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 = [ @@ -189,7 +193,21 @@ export default class FromWidgetPostMessageApi { const data = event.data.data || event.data.widgetData; const integType = (data && data.integType) ? data.integType : null; const integId = (data && data.integId) ? data.integId : null; - IntegrationManager.open(integType, 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/HtmlUtils.js b/src/HtmlUtils.js index 626b228357..6ede36ee81 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 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. @@ -26,7 +27,6 @@ import * as linkify from 'linkifyjs'; import linkifyMatrix from './linkify-matrix'; import _linkifyElement from 'linkifyjs/element'; import _linkifyString from 'linkifyjs/string'; -import escape from 'lodash/escape'; import classNames from 'classnames'; import MatrixClientPeg from './MatrixClientPeg'; import url from 'url'; @@ -51,11 +51,14 @@ const ZWJ_REGEX = new RegExp("\u200D|\u2003", "g"); const WHITESPACE_REGEX = new RegExp("\\s", "g"); const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); +const SINGLE_EMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})$`, 'i'); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; +const VARIATION_SELECTOR = String.fromCharCode(0xFE0F); + /* * Return true if the given string contains emoji * Uses a much, much simpler regex than emojibase's so will give false @@ -63,7 +66,7 @@ const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; * need emojification. * unicodeToImage uses this function. */ -export function containsEmoji(str) { +function mightContainEmoji(str) { return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); } @@ -74,7 +77,10 @@ export function containsEmoji(str) { * @return {String} The shortcode (such as :thumbup:) */ export function unicodeToShortcode(char) { - const data = EMOJIBASE.find(e => e.unicode === char); + // Check against both the char and the char with an empty variation selector appended because that's how + // emoji-base stores its base emojis which have variations. https://github.com/vector-im/riot-web/issues/9785 + const emptyVariation = char + VARIATION_SELECTOR; + const data = EMOJIBASE.find(e => e.unicode === char || e.unicode === emptyVariation); return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : ''); } @@ -428,7 +434,7 @@ export function bodyToHtml(content, highlights, opts={}) { if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody); strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : content.body; - bodyHasEmoji = containsEmoji(isHtmlMessage ? formattedBody : content.body); + bodyHasEmoji = mightContainEmoji(isHtmlMessage ? formattedBody : content.body); // Only generate safeBody if the message was sent as org.matrix.custom.html if (isHtmlMessage) { @@ -462,14 +468,14 @@ export function bodyToHtml(content, highlights, opts={}) { // their username ( content.formatted_body == undefined || - !content.formatted_body.includes("https://matrix.to/") + !content.formatted_body.includes("https://matrix.to/") ); } const className = classNames({ 'mx_EventTile_body': true, 'mx_EventTile_bigEmoji': emojiBody, - 'markdown-body': isHtmlMessage, + 'markdown-body': isHtmlMessage && !emojiBody, }); return isDisplayedWithHtml ? @@ -507,3 +513,38 @@ export function linkifyElement(element, options = linkifyMatrix.options) { export function linkifyAndSanitizeHtml(dirtyHtml) { return sanitizeHtml(linkifyString(dirtyHtml), sanitizeHtmlParams); } + +/** + * Returns if a node is a block element or not. + * Only takes html nodes into account that are allowed in matrix messages. + * + * @param {Node} node + * @returns {bool} + */ +export function checkBlockNode(node) { + switch (node.nodeName) { + case "H1": + case "H2": + case "H3": + case "H4": + case "H5": + case "H6": + case "PRE": + case "BLOCKQUOTE": + case "DIV": + case "P": + case "UL": + case "OL": + case "LI": + case "HR": + case "TABLE": + case "THEAD": + case "TBODY": + case "TR": + case "TH": + case "TD": + return true; + default: + return false; + } +} diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js new file mode 100644 index 0000000000..075ae93709 --- /dev/null +++ b/src/IdentityAuthClient.js @@ -0,0 +1,150 @@ +/* +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 { createClient, SERVICE_TYPES } from 'matrix-js-sdk'; + +import MatrixClientPeg from './MatrixClientPeg'; +import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; + +export default class IdentityAuthClient { + /** + * 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() { + return this.accessToken != null; // undef or null + } + + // Returns a promise that resolves to the access_token string from the IS + async getAccessToken(check=true) { + if (!this.authEnabled) { + // The current IS doesn't support authentication + return null; + } + + let token = this.accessToken; + if (!token) { + token = this._readToken(); + } + + if (!token) { + token = await this.registerForToken(check); + if (token) { + this.accessToken = token; + this._writeToken(); + } + return 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(); + } + } + } + + return token; + } + + async _checkToken(token) { + try { + 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, + this._matrixClient.getIdentityServerUrl(), + token, + )]); + return; + } + throw e; + } + + // We should ensure the token in `localStorage` is cleared + // appropriately. We already clear storage on sign out, but we'll need + // additional clearing when changing ISes in settings as part of future + // privacy work. + // See also https://github.com/vector-im/riot-web/issues/10455. + } + + async registerForToken(check=true) { + try { + const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken(); + const { access_token: identityAccessToken } = + await this._matrixClient.registerWithIdentityServer(hsOpenIdToken); + if (check) await this._checkToken(identityAccessToken); + return identityAccessToken; + } catch (e) { + if (e.cors === "rejected" || e.httpStatus === 404) { + // Assume IS only supports deprecated v1 API for now + // TODO: Remove this path once v2 is only supported version + // See https://github.com/vector-im/riot-web/issues/10443 + console.warn("IS doesn't support v2 auth"); + this.authEnabled = false; + return; + } + throw e; + } + } +} diff --git a/src/IntegrationManager.js b/src/IntegrationManager.js deleted file mode 100644 index 165ee6390d..0000000000 --- a/src/IntegrationManager.js +++ /dev/null @@ -1,78 +0,0 @@ -/* -Copyright 2017 New Vector Ltd - -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 Modal from './Modal'; -import sdk from './index'; -import SdkConfig from './SdkConfig'; -import ScalarMessaging from './ScalarMessaging'; -import ScalarAuthClient from './ScalarAuthClient'; -import RoomViewStore from './stores/RoomViewStore'; - -if (!global.mxIntegrationManager) { - global.mxIntegrationManager = {}; -} - -export default class IntegrationManager { - static _init() { - if (!global.mxIntegrationManager.client || !global.mxIntegrationManager.connected) { - if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) { - ScalarMessaging.startListening(); - global.mxIntegrationManager.client = new ScalarAuthClient(); - - return global.mxIntegrationManager.client.connect().then(() => { - global.mxIntegrationManager.connected = true; - }).catch((e) => { - console.error("Failed to connect to integrations server", e); - global.mxIntegrationManager.error = e; - }); - } else { - console.error('Invalid integration manager config', SdkConfig.get()); - } - } - } - - /** - * Launch the integrations manager on the stickers integration page - * @param {string} integName integration / widget type - * @param {string} integId integration / widget ID - * @param {function} onFinished Callback to invoke on integration manager close - */ - static async open(integName, integId, onFinished) { - await IntegrationManager._init(); - if (global.mxIntegrationManager.client) { - await global.mxIntegrationManager.client.connect(); - } else { - return; - } - const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); - if (global.mxIntegrationManager.error || - !(global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials())) { - console.error("Scalar error", global.mxIntegrationManager); - return; - } - const integType = 'type_' + integName; - const src = (global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials()) ? - global.mxIntegrationManager.client.getScalarInterfaceUrlForRoom( - {roomId: RoomViewStore.getRoomId()}, - integType, - integId, - ) : - null; - Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { - src: src, - onFinished: onFinished, - }, "mx_IntegrationsManager"); - } -} diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js index 0b54d88e5f..c3de7988b2 100644 --- a/src/KeyRequestHandler.js +++ b/src/KeyRequestHandler.js @@ -125,7 +125,7 @@ export default class KeyRequestHandler { }; const KeyShareDialog = sdk.getComponent("dialogs.KeyShareDialog"); - Modal.createTrackedDialog('Key Share', 'Process Next Request', KeyShareDialog, { + Modal.appendTrackedDialog('Key Share', 'Process Next Request', KeyShareDialog, { matrixClient: this._matrixClient, userId: userId, deviceId: deviceId, diff --git a/src/Keyboard.js b/src/Keyboard.js index bf83a1a05f..fb7d692ce3 100644 --- a/src/Keyboard.js +++ b/src/Keyboard.js @@ -58,6 +58,7 @@ export const KeyCode = { KEY_X: 88, KEY_Y: 89, KEY_Z: 90, + KEY_BACKTICK: 223, }; export function isOnlyCtrlOrCmdKeyEvent(ev) { diff --git a/src/Lifecycle.js b/src/Lifecycle.js index a7f90f847d..c03a958840 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -33,6 +33,9 @@ import ActiveWidgetStore from './stores/ActiveWidgetStore'; import PlatformPeg from "./PlatformPeg"; 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 @@ -64,6 +67,9 @@ import * as StorageManager from './utils/StorageManager'; * @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is * true; defines the IS to use. * + * @params {bool} opts.ignoreGuest: If the stored session is a guest account, ignore + * it and don't load it. + * * @returns {Promise} a promise which resolves when the above process completes. * Resolves to `true` if we ended up starting a session, or `false` if we * failed. @@ -76,7 +82,7 @@ export async function loadSession(opts) { const fragmentQueryParams = opts.fragmentQueryParams || {}; const defaultDeviceDisplayName = opts.defaultDeviceDisplayName; - if (!guestHsUrl) { + if (enableGuest && !guestHsUrl) { console.warn("Cannot enable guest access: can't determine HS URL to use"); enableGuest = false; } @@ -94,7 +100,9 @@ export async function loadSession(opts) { guest: true, }, true).then(() => true); } - const success = await _restoreFromLocalStorage(); + const success = await _restoreFromLocalStorage({ + ignoreGuest: Boolean(opts.ignoreGuest), + }); if (success) { return true; } @@ -122,7 +130,7 @@ export async function loadSession(opts) { * @returns {String} The persisted session's owner, if an owner exists. Null otherwise. */ export function getStoredSessionOwner() { - const {hsUrl, userId, accessToken} = _getLocalStorageSessionVars(); + const {hsUrl, userId, accessToken} = getLocalStorageSessionVars(); return hsUrl && userId && accessToken ? userId : null; } @@ -131,7 +139,7 @@ export function getStoredSessionOwner() { * for a real user. If there is no stored session, return null. */ export function getStoredSessionIsGuest() { - const sessVars = _getLocalStorageSessionVars(); + const sessVars = getLocalStorageSessionVars(); return sessVars.hsUrl && sessVars.userId && sessVars.accessToken ? sessVars.isGuest : null; } @@ -232,14 +240,19 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { guest: true, }, true).then(() => true); }, (err) => { - console.error("Failed to register as guest: " + err + " " + err.data); + console.error("Failed to register as guest", err); return false; }); } -function _getLocalStorageSessionVars() { +/** + * Retrieves information about the stored session in localstorage. The session + * may not be valid, as it is not tested for consistency here. + * @returns {Object} Information about the session - see implementation for variables. + */ +export function getLocalStorageSessionVars() { const hsUrl = localStorage.getItem("mx_hs_url"); - const isUrl = localStorage.getItem("mx_is_url") || 'https://matrix.org'; + const isUrl = localStorage.getItem("mx_is_url"); const accessToken = localStorage.getItem("mx_access_token"); const userId = localStorage.getItem("mx_user_id"); const deviceId = localStorage.getItem("mx_device_id"); @@ -265,14 +278,21 @@ function _getLocalStorageSessionVars() { // The plan is to gradually move the localStorage access done here into // SessionStore to avoid bugs where the view becomes out-of-sync with // localStorage (e.g. isGuest etc.) -async function _restoreFromLocalStorage() { +async function _restoreFromLocalStorage(opts) { + const ignoreGuest = opts.ignoreGuest; + if (!localStorage) { return false; } - const {hsUrl, isUrl, accessToken, userId, deviceId, isGuest} = _getLocalStorageSessionVars(); + const {hsUrl, isUrl, accessToken, userId, deviceId, isGuest} = getLocalStorageSessionVars(); if (accessToken && userId && hsUrl) { + if (ignoreGuest && isGuest) { + console.log("Ignoring stored guest account: " + userId); + return false; + } + console.log(`Restoring session for ${userId}`); await _doSetLoggedIn({ userId: userId, @@ -333,6 +353,37 @@ export function setLoggedIn(credentials) { return _doSetLoggedIn(credentials, true); } +/** + * Hydrates an existing session by using the credentials provided. This will + * not clear any local storage, unlike setLoggedIn(). + * + * Stops the existing Matrix client (without clearing its data) and starts a + * new one in its place. This additionally starts all other react-sdk services + * which use the new Matrix client. + * + * If the credentials belong to a different user from the session already stored, + * the old session will be cleared automatically. + * + * @param {MatrixClientCreds} credentials The credentials to use + * + * @returns {Promise} promise which resolves to the new MatrixClient once it has been started + */ +export function hydrateSession(credentials) { + const oldUserId = MatrixClientPeg.get().getUserId(); + const oldDeviceId = MatrixClientPeg.get().getDeviceId(); + + stopMatrixClient(); // unsets MatrixClientPeg.get() + localStorage.removeItem("mx_soft_logout"); + _isLoggingOut = false; + + const overwrite = credentials.userId !== oldUserId || credentials.deviceId !== oldDeviceId; + if (overwrite) { + console.warn("Clearing all data: Old session belongs to a different user/device"); + } + + return _doSetLoggedIn(credentials, overwrite); +} + /** * fires on_logging_in, optionally clears localstorage, persists new credentials * to localstorage, starts the new client. @@ -345,11 +396,14 @@ export function setLoggedIn(credentials) { async function _doSetLoggedIn(credentials, clearStorage) { credentials.guest = Boolean(credentials.guest); + const softLogout = isSoftLogout(); + console.log( "setLoggedIn: mxid: " + credentials.userId + " deviceId: " + credentials.deviceId + " guest: " + credentials.guest + - " hs: " + credentials.homeserverUrl, + " hs: " + credentials.homeserverUrl + + " softLogout: " + softLogout, ); // This is dispatched to indicate that the user is still in the process of logging in @@ -407,7 +461,7 @@ async function _doSetLoggedIn(credentials, clearStorage) { dis.dispatch({ action: 'on_logged_in' }); - await startMatrixClient(); + await startMatrixClient(/*startSyncing=*/!softLogout); return MatrixClientPeg.get(); } @@ -426,7 +480,9 @@ class AbortLoginAndRebuildStorage extends Error { } function _persistCredentialsToLocalStorage(credentials) { localStorage.setItem("mx_hs_url", credentials.homeserverUrl); - localStorage.setItem("mx_is_url", credentials.identityServerUrl); + if (credentials.identityServerUrl) { + localStorage.setItem("mx_is_url", credentials.identityServerUrl); + } localStorage.setItem("mx_user_id", credentials.userId); localStorage.setItem("mx_access_token", credentials.accessToken); localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); @@ -480,6 +536,25 @@ export function logout() { ).done(); } +export function softLogout() { + if (!MatrixClientPeg.get()) return; + + // Track that we've detected and trapped a soft logout. This helps prevent other + // parts of the app from starting if there's no point (ie: don't sync if we've + // been soft logged out, despite having credentials and data for a MatrixClient). + localStorage.setItem("mx_soft_logout", "true"); + + _isLoggingOut = true; // to avoid repeated flags + stopMatrixClient(/*unsetClient=*/false); + dis.dispatch({action: 'on_client_not_viable'}); // generic version of on_logged_out + + // DO NOT CALL LOGOUT. A soft logout preserves data, logout does not. +} + +export function isSoftLogout() { + return localStorage.getItem("mx_soft_logout") === "true"; +} + export function isLoggingOut() { return _isLoggingOut; } @@ -487,8 +562,10 @@ export function isLoggingOut() { /** * Starts the matrix client and all other react-sdk services that * listen for events while a session is logged in. + * @param {boolean} startSyncing True (default) to actually start + * syncing the client. */ -async function startMatrixClient() { +async function startMatrixClient(startSyncing=true) { console.log(`Lifecycle: Starting MatrixClient`); // dispatch this before starting the matrix client: it's used @@ -499,15 +576,28 @@ async function startMatrixClient() { Notifier.start(); UserActivity.sharedInstance().start(); - Presence.start(); + TypingStore.sharedInstance().reset(); // just in case + if (!SettingsStore.getValue("lowBandwidth")) { + Presence.start(); + } DMRoomMap.makeShared().start(); + IntegrationManagers.sharedInstance().startWatching(); ActiveWidgetStore.start(); - await MatrixClientPeg.start(); + if (startSyncing) { + await MatrixClientPeg.start(); + } else { + console.warn("Caller requested only auxiliary services be started"); + await MatrixClientPeg.assign(); + } // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. dis.dispatch({action: 'client_started'}); + + if (isSoftLogout()) { + softLogout(); + } } /* @@ -541,17 +631,24 @@ function _clearStorage() { /** * Stop all the background processes related to the current client. + * @param {boolean} unsetClient True (default) to abandon the client + * on MatrixClientPeg after stopping. */ -export function stopMatrixClient() { +export function stopMatrixClient(unsetClient=true) { Notifier.stop(); UserActivity.sharedInstance().stop(); + TypingStore.sharedInstance().reset(); Presence.stop(); ActiveWidgetStore.stop(); + IntegrationManagers.sharedInstance().stopWatching(); if (DMRoomMap.shared()) DMRoomMap.shared().stop(); const cli = MatrixClientPeg.get(); if (cli) { cli.stopClient(); cli.removeAllListeners(); - MatrixClientPeg.unset(); + + if (unsetClient) { + MatrixClientPeg.unset(); + } } } 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 391d089cc6..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, @@ -51,6 +52,7 @@ interface MatrixClientCreds { class MatrixClientPeg { constructor() { this.matrixClient = null; + this._justRegisteredUserId = null; // These are the default options used when when the // client is started in 'start'. These can be altered @@ -85,6 +87,31 @@ class MatrixClientPeg { MatrixActionCreators.stop(); } + /* + * If we've registered a user ID we set this to the ID of the + * user we've just registered. If they then go & log in, we + * can send them to the welcome user (obviously this doesn't + * guarentee they'll get a chat with the welcome user). + * + * @param {string} uid The user ID of the user we've just registered + */ + setJustRegisteredUserId(uid) { + this._justRegisteredUserId = uid; + } + + /* + * Returns true if the current user has just been registered by this + * client as determined by setJustRegisteredUserId() + * + * @returns {bool} True if user has just been registered + */ + currentUserIsJustRegistered() { + return ( + this.matrixClient && + this.matrixClient.credentials.userId === this._justRegisteredUserId + ); + } + /** * Replace this MatrixClientPeg's client with a client instance that has * homeserver / identity server URLs and active credentials @@ -94,7 +121,7 @@ class MatrixClientPeg { this._createClient(creds); } - async start() { + async assign() { for (const dbType of ['indexeddb', 'memory']) { try { const promise = this.matrixClient.store.startup(); @@ -105,7 +132,7 @@ class MatrixClientPeg { if (dbType === 'indexeddb') { console.error('Error starting matrixclient store - falling back to memory store', err); this.matrixClient.store = new Matrix.MemoryStore({ - localStorage: global.localStorage, + localStorage: global.localStorage, }); } else { console.error('Failed to start memory store!', err); @@ -119,7 +146,7 @@ class MatrixClientPeg { // try to initialise e2e on the new client try { // check that we have a version of the js-sdk which includes initCrypto - if (this.matrixClient.initCrypto) { + if (!SettingsStore.getValue("lowBandwidth") && this.matrixClient.initCrypto) { await this.matrixClient.initCrypto(); StorageManager.setCryptoInitialised(true); } @@ -146,6 +173,12 @@ class MatrixClientPeg { MatrixActionCreators.start(this.matrixClient); MatrixClientBackedSettingsHandler.matrixClient = this.matrixClient; + return opts; + } + + async start() { + const opts = await this.assign(); + console.log(`MatrixClientPeg: really starting MatrixClient`); await this.get().startClient(opts); console.log(`MatrixClientPeg: MatrixClient started`); @@ -167,7 +200,7 @@ class MatrixClientPeg { * Throws an error if unable to deduce the homeserver name * (eg. if the user is not logged in) */ - getHomeServerName() { + getHomeserverName() { const matches = /^@.+:(.+)$/.exec(this.matrixClient.credentials.userId); if (matches === null || matches.length < 1) { throw new Error("Failed to derive homeserver name from user ID!"); @@ -176,9 +209,6 @@ class MatrixClientPeg { } _createClient(creds: MatrixClientCreds) { - const aggregateRelations = SettingsStore.isFeatureEnabled("feature_reactions"); - const enableEdits = SettingsStore.isFeatureEnabled("feature_message_editing"); - const opts = { baseUrl: creds.homeserverUrl, idBaseUrl: creds.identityServerUrl, @@ -187,8 +217,10 @@ class MatrixClientPeg { deviceId: creds.deviceId, timelineSupport: true, forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false), + fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'), verificationMethods: [verificationMethods.SAS], - unstableClientRelationAggregation: aggregateRelations || enableEdits, + unstableClientRelationAggregation: true, + identityServer: new IdentityAuthClient(), }; this.matrixClient = createMatrixClient(opts); diff --git a/src/Modal.js b/src/Modal.js index a114ad2d3c..26c9da8bbb 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -15,15 +15,15 @@ limitations under the License. */ -'use strict'; - -const React = require('react'); -const ReactDOM = require('react-dom'); +import React from 'react'; +import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; 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"; @@ -32,7 +32,7 @@ const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; * Wrap an asynchronous loader function with a react component which shows a * spinner until the real component loads. */ -const AsyncWrapper = React.createClass({ +const AsyncWrapper = createReactClass({ propTypes: { /** A promise which resolves with the real component */ @@ -156,15 +156,79 @@ class ModalManager { return this.createDialog(...rest); } + appendTrackedDialog(analyticsAction, analyticsInfo, ...rest) { + Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); + return this.appendDialog(...rest); + } + createDialog(Element, ...rest) { return this.createDialogAsync(Promise.resolve(Element), ...rest); } + appendDialog(Element, ...rest) { + return this.appendDialogAsync(Promise.resolve(Element), ...rest); + } + createTrackedDialogAsync(analyticsAction, analyticsInfo, ...rest) { Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); return this.createDialogAsync(...rest); } + appendTrackedDialogAsync(analyticsAction, analyticsInfo, ...rest) { + Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); + return this.appendDialogAsync(...rest); + } + + _buildModal(prom, props, className) { + const modal = {}; + + // never call this from onFinished() otherwise it will loop + const [closeDialog, onFinishedProm] = this._getCloseFn(modal, props); + + // don't attempt to reuse the same AsyncWrapper for different dialogs, + // otherwise we'll get confused. + const modalCount = this._counter++; + + // FIXME: If a dialog uses getDefaultProps it clobbers the onFinished + // property set here so you can't close the dialog from a button click! + modal.elem = ( + + ); + modal.onFinished = props ? props.onFinished : null; + modal.className = className; + + return {modal, closeDialog, onFinishedProm}; + } + + _getCloseFn(modal, props) { + 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) { + this._modals.splice(i, 1); + } + + if (this._priorityModal === modal) { + this._priorityModal = null; + + // XXX: This is destructive + this._modals = []; + } + + if (this._staticModal === modal) { + this._staticModal = null; + + // XXX: This is destructive + this._modals = []; + } + + this._reRender(); + }, deferred.promise]; + } + /** * Open a modal view. * @@ -195,46 +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 = {}; - - // never call this from onFinished() otherwise it will loop - // - const closeDialog = (...args) => { - if (props && props.onFinished) props.onFinished.apply(null, args); - const i = this._modals.indexOf(modal); - if (i >= 0) { - this._modals.splice(i, 1); - } - - if (this._priorityModal === modal) { - this._priorityModal = null; - - // XXX: This is destructive - this._modals = []; - } - - if (this._staticModal === modal) { - this._staticModal = null; - - // XXX: This is destructive - this._modals = []; - } - - this._reRender(); - }; - - // don't attempt to reuse the same AsyncWrapper for different dialogs, - // otherwise we'll get confused. - const modalCount = this._counter++; - - // FIXME: If a dialog uses getDefaultProps it clobbers the onFinished - // property set here so you can't close the dialog from a button click! - modal.elem = ( - - ); - modal.onFinished = props ? props.onFinished : null; - modal.className = className; + const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className); if (isPriorityModal) { // XXX: This is destructive @@ -247,7 +272,21 @@ class ModalManager { } this._reRender(); - return {close: closeDialog}; + return { + close: closeDialog, + finished: onFinishedProm, + }; + } + + appendDialogAsync(prom, props, className) { + const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className); + + this._modals.push(modal); + this._reRender(); + return { + close: closeDialog, + finished: onFinishedProm, + }; } closeAll() { diff --git a/src/Notifier.js b/src/Notifier.js index 6a4f9827f7..0b0a5f6990 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -85,7 +85,11 @@ const Notifier = { msg = ''; } - const avatarUrl = ev.sender ? Avatar.avatarUrlForMember(ev.sender, 40, 40, 'crop') : null; + let avatarUrl = null; + if (ev.sender && !SettingsStore.getValue("lowBandwidth")) { + avatarUrl = Avatar.avatarUrlForMember(ev.sender, 40, 40, 'crop'); + } + const notif = plaf.displayNotification(title, msg, avatarUrl, room); // if displayNotification returns non-null, the platform supports @@ -96,10 +100,55 @@ const Notifier = { } }, - _playAudioNotification: function(ev, room) { - const e = document.getElementById("messageAudio"); - if (e) { - e.play(); + getSoundForRoom: async function(roomId) { + // We do no caching here because the SDK caches setting + // and the browser will cache the sound. + const content = SettingsStore.getValue("notificationSound", roomId); + if (!content) { + return null; + } + + if (!content.url) { + console.warn(`${roomId} has custom notification sound event, but no url key`); + return null; + } + + if (!content.url.startsWith("mxc://")) { + console.warn(`${roomId} has custom notification sound event, but url is not a mxc url`); + return null; + } + + // Ideally in here we could use MSC1310 to detect the type of file, and reject it. + + return { + url: MatrixClientPeg.get().mxcUrlToHttp(content.url), + name: content.name, + type: content.type, + size: content.size, + }; + }, + + _playAudioNotification: async function(ev, room) { + const sound = await this.getSoundForRoom(room.roomId); + console.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`); + + try { + const selector = document.querySelector(sound ? `audio[src='${sound.url}']` : "#messageAudio"); + let audioElement = selector; + if (!selector) { + if (!sound) { + console.error("No audio element or sound to play for notification"); + return; + } + audioElement = new Audio(sound.url); + if (sound.type) { + audioElement.type = sound.type; + } + document.body.appendChild(audioElement); + } + audioElement.play(); + } catch (ex) { + console.warn("Caught error when trying to fetch room notification sound:", ex); } }, 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 34b9635780..b2382e206f 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -42,23 +42,36 @@ function inviteMultipleToRoom(roomId, addrs) { export function showStartChatInviteDialog() { const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + + const validAddressTypes = ['mx-user-id']; + if (MatrixClientPeg.get().getIdentityServerUrl()) { + validAddressTypes.push('email'); + } + 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"), - validAddressTypes: ['mx-user-id', 'email'], + validAddressTypes, button: _t("Start Chat"), - onFinished: _onStartChatFinished, + onFinished: _onStartDmFinished, }); } export function showRoomInviteDialog(roomId) { const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + + const validAddressTypes = ['mx-user-id']; + if (MatrixClientPeg.get().getIdentityServerUrl()) { + validAddressTypes.push('email'); + } + 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"), + validAddressTypes, onFinished: (shouldInvite, addrs) => { _onRoomInviteFinished(roomId, shouldInvite, addrs); }, @@ -83,7 +96,8 @@ export function isValid3pidInvite(event) { return true; } -function _onStartChatFinished(shouldInvite, addrs) { +// TODO: Immutable DMs replaces this +function _onStartDmFinished(shouldInvite, addrs) { if (!shouldInvite) return; const addrTexts = addrs.map((addr) => addr.address); @@ -91,32 +105,19 @@ function _onStartChatFinished(shouldInvite, addrs) { if (_isDmChat(addrTexts)) { const rooms = _getDirectMessageRooms(addrTexts[0]); if (rooms.length > 0) { - // A Direct Message room already exists for this user, so select a - // room from a list that is similar to the one in MemberInfo panel - const ChatCreateOrReuseDialog = sdk.getComponent("views.dialogs.ChatCreateOrReuseDialog"); - const close = Modal.createTrackedDialog('Create or Reuse', '', ChatCreateOrReuseDialog, { - userId: addrTexts[0], - onNewDMClick: () => { - dis.dispatch({ - action: 'start_chat', - user_id: addrTexts[0], - }); - close(true); - }, - onExistingRoomSelected: (roomId) => { - dis.dispatch({ - action: 'view_room', - room_id: roomId, - }); - close(true); - }, - }).close; + // A Direct Message room already exists for this user, so reuse it + dis.dispatch({ + action: 'view_room', + room_id: rooms[0], + should_peek: false, + joining: false, + }); } else { // Start a new DM chat createRoom({dmUserId: addrTexts[0]}).catch((err) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, { - title: _t("Failed to invite user"), + Modal.createTrackedDialog('Failed to start chat', '', ErrorDialog, { + title: _t("Failed to start chat"), description: ((err && err.message) ? err.message : _t("Operation failed")), }); }); @@ -125,8 +126,8 @@ function _onStartChatFinished(shouldInvite, addrs) { // Start a new DM chat createRoom({dmUserId: addrTexts[0]}).catch((err) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, { - title: _t("Failed to invite user"), + Modal.createTrackedDialog('Failed to start chat', '', ErrorDialog, { + title: _t("Failed to start chat"), description: ((err && err.message) ? err.message : _t("Operation failed")), }); }); @@ -168,6 +169,7 @@ function _onRoomInviteFinished(roomId, shouldInvite, addrs) { }); } +// TODO: Immutable DMs replaces this function _isDmChat(addrTexts) { if (addrTexts.length === 1 && getAddressType(addrTexts[0]) === 'mx-user-id') { return true; diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index 39384b5bea..2d5e4b3136 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -1,5 +1,6 @@ /* Copyright 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. @@ -26,13 +27,33 @@ export const MUTE = 'mute'; export const BADGE_STATES = [ALL_MESSAGES, ALL_MESSAGES_LOUD]; export const MENTION_BADGE_STATES = [...BADGE_STATES, MENTIONS_ONLY]; -function _shouldShowNotifBadge(roomNotifState) { - const showBadgeInStates = [ALL_MESSAGES, ALL_MESSAGES_LOUD]; - return showBadgeInStates.indexOf(roomNotifState) > -1; +export function shouldShowNotifBadge(roomNotifState) { + return BADGE_STATES.includes(roomNotifState); } -function _shouldShowMentionBadge(roomNotifState) { - return roomNotifState !== MUTE; +export function shouldShowMentionBadge(roomNotifState) { + return MENTION_BADGE_STATES.includes(roomNotifState); +} + +export function countRoomsWithNotif(rooms) { + return rooms.reduce((result, room, index) => { + const roomNotifState = getRoomNotifsState(room.roomId); + const highlight = room.getUnreadNotificationCount('highlight') > 0; + const notificationCount = room.getUnreadNotificationCount(); + + const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState); + const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState); + const isInvite = room.hasMembershipState(MatrixClientPeg.get().credentials.userId, 'invite'); + const badges = notifBadges || mentionBadges || isInvite; + + if (badges) { + result.count++; + if (highlight) { + result.highlight = true; + } + } + return result; + }, {count: 0, highlight: false}); } export function aggregateNotificationCount(rooms) { @@ -41,8 +62,8 @@ export function aggregateNotificationCount(rooms) { const highlight = room.getUnreadNotificationCount('highlight') > 0; const notificationCount = room.getUnreadNotificationCount(); - const notifBadges = notificationCount > 0 && _shouldShowNotifBadge(roomNotifState); - const mentionBadges = highlight && _shouldShowMentionBadge(roomNotifState); + const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState); + const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState); const badges = notifBadges || mentionBadges; if (badges) { @@ -60,8 +81,8 @@ export function getRoomHasBadge(room) { const highlight = room.getUnreadNotificationCount('highlight') > 0; const notificationCount = room.getUnreadNotificationCount(); - const notifBadges = notificationCount > 0 && _shouldShowNotifBadge(roomNotifState); - const mentionBadges = highlight && _shouldShowMentionBadge(roomNotifState); + const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState); + const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState); return notifBadges || mentionBadges; } diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index 24979aff65..3623d47f8e 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -1,5 +1,6 @@ /* Copyright 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,19 +15,61 @@ See the License for the specific language governing permissions and limitations under the License. */ +import url from 'url'; import Promise from 'bluebird'; import SettingsStore from "./settings/SettingsStore"; +import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; const request = require('browser-request'); const SdkConfig = require('./SdkConfig'); const MatrixClientPeg = require('./MatrixClientPeg'); +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; + } + + _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) { + this.termsInteractionCallback = callback; } connect() { @@ -39,31 +82,25 @@ class ScalarAuthClient { return this.scalarToken != null; // undef or null } - // Returns a scalar_token string + // Returns a promise that resolves to a scalar_token string getScalarToken() { - const token = window.localStorage.getItem("mx_scalar_token"); + const token = this._readToken(); if (!token) { return this.registerForToken(); } else { - return this.validateToken(token).then(userId => { - const me = MatrixClientPeg.get().getUserId(); - if (userId !== me) { - throw new Error("Scalar token is owned by someone else: " + me); + return this._checkToken(token).catch((e) => { + if (e instanceof TermsNotSignedError) { + // retrying won't help this + throw e; } - return token; - }).catch(err => { - console.error(err); - - // Something went wrong - try to get a new token. - console.warn("Registering for new scalar token"); return this.registerForToken(); }); } } - validateToken(token) { - const url = SdkConfig.get().integrations_rest_url + "/account"; + _getAccountName(token) { + const url = this.apiUrl + "/account"; return new Promise(function(resolve, reject) { request({ @@ -74,8 +111,10 @@ class ScalarAuthClient { }, (err, response, body) => { if (err) { reject(err); + } else if (body && body.errcode === 'M_TERMS_NOT_SIGNED') { + reject(new TermsNotSignedError()); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(body); } else if (!body || !body.user_id) { reject(new Error("Missing user_id in response")); } else { @@ -85,26 +124,70 @@ class ScalarAuthClient { }); } - registerForToken() { - // Get openid bearer token from the HS as the first part of our dance - return MatrixClientPeg.get().getOpenIdToken().then((token_object) => { - // Now we can send that to scalar and exchange it for a scalar token - return this.exchangeForScalarToken(token_object); - }).then((token_object) => { - window.localStorage.setItem("mx_scalar_token", token_object); - return token_object; + _checkToken(token) { + return this._getAccountName(token).then(userId => { + const me = MatrixClientPeg.get().getUserId(); + if (userId !== me) { + throw new Error("Scalar token is owned by someone else: " + me); + } + return token; + }).catch((e) => { + if (e instanceof TermsNotSignedError) { + 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 + + // We continue to use the full URL for the calls done by + // matrix-react-sdk, but the standard terms API called + // by the js-sdk lives on the standard _matrix path. This means we + // don't support running IMs on a non-root path, but it's the only + // realistic way of transitioning to _matrix paths since configs in + // the wild contain bits of the API path. + + // 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(this.apiUrl); + parsedImRestUrl.path = ''; + parsedImRestUrl.pathname = ''; + return startTermsFlow([new Service( + Matrix.SERVICE_TYPES.IM, + parsedImRestUrl.format(), + token, + )], this.termsInteractionCallback).then(() => { + return token; + }); + } else { + throw e; + } }); } - exchangeForScalarToken(openid_token_object) { - const scalar_rest_url = SdkConfig.get().integrations_rest_url; + registerForToken() { + // Get openid bearer token from the HS as the first part of our dance + 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((token) => { + // Validate it (this mostly checks to see if the IM needs us to agree to some terms) + return this._checkToken(token); + }).then((token) => { + this.scalarToken = token; + this._writeTokenToStore(); + return token; + }); + } + + exchangeForScalarToken(openidTokenObject) { + const scalarRestUrl = this.apiUrl; return new Promise(function(resolve, reject) { request({ method: 'POST', - uri: scalar_rest_url+'/register', + uri: scalarRestUrl + '/register', qs: {v: imApiVersion}, - body: openid_token_object, + body: openidTokenObject, json: true, }, (err, response, body) => { if (err) { @@ -121,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); @@ -157,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({ @@ -186,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); @@ -204,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 fa7b8c5b76..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)); @@ -546,20 +546,30 @@ const onMessage = function(event) { // This means the URL could contain a path (like /develop) and still be used // to validate event origins, which do not specify paths. // (See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) - // - // All strings start with the empty string, so for sanity return if the length - // of the event origin is 0. - // + let configUrl; + try { + if (!openManagerUrl) openManagerUrl = IntegrationManagers.sharedInstance().getPrimaryManager().uiUrl; + configUrl = new URL(openManagerUrl); + } catch (e) { + // No integrations UI URL, ignore silently. + return; + } + let eventOriginUrl; + try { + eventOriginUrl = new URL(event.origin); + } catch (e) { + return; + } // TODO -- Scalar postMessage API should be namespaced with event.data.api field // Fix following "if" statement to respond only to specific API messages. - const url = SdkConfig.get().integrations_ui_url; if ( - event.origin.length === 0 || - !url.startsWith(event.origin + '/') || + configUrl.origin !== eventOriginUrl.origin || !event.data.action || event.data.api // Ignore messages with specific API set ) { - return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise + // don't log this - debugging APIs and browser add-ons like to spam + // postMessage which floods the log otherwise + return; } if (event.data.action === "close_scalar") { @@ -647,6 +657,7 @@ const onMessage = function(event) { }; let listenerCount = 0; +let openManagerUrl = null; module.exports = { startListening: function() { if (listenerCount === 0) { @@ -669,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 f25bc9af07..5ed1adb40f 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -20,11 +20,9 @@ limitations under the License. import React from 'react'; import MatrixClientPeg from './MatrixClientPeg'; import dis from './dispatcher'; -import Tinter from './Tinter'; import sdk from './index'; import {_t, _td} from './languageHandler'; import Modal from './Modal'; -import SettingsStore, {SettingLevel} from './settings/SettingsStore'; import {MATRIXTO_URL_PATTERN} from "./linkify-matrix"; import * as querystring from "querystring"; import MultiInviter from './utils/MultiInviter'; @@ -34,12 +32,41 @@ import WidgetUtils from "./utils/WidgetUtils"; import {textToHtmlRainbow} from "./utils/colour"; import Promise from "bluebird"; +const singleMxcUpload = async () => { + return new Promise((resolve) => { + const fileSelector = document.createElement('input'); + fileSelector.setAttribute('type', 'file'); + fileSelector.onchange = (ev) => { + const file = ev.target.files[0]; + + const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); + Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, { + file, + onFinished: (shouldContinue) => { + resolve(shouldContinue ? MatrixClientPeg.get().uploadContent(file) : null); + }, + }); + }; + + fileSelector.click(); + }); +}; + +export const CommandCategories = { + "messages": _td("Messages"), + "actions": _td("Actions"), + "admin": _td("Admin"), + "advanced": _td("Advanced"), + "other": _td("Other"), +}; + class Command { - constructor({name, args='', description, runFn, hideCompletionAfterSpace=false}) { + constructor({name, args='', description, runFn, category=CommandCategories.other, hideCompletionAfterSpace=false}) { this.command = '/' + name; this.args = args; this.description = description; this.runFn = runFn; + this.category = category; this.hideCompletionAfterSpace = hideCompletionAfterSpace; } @@ -86,6 +113,7 @@ export const CommandMap = { } return success(MatrixClientPeg.get().sendTextMessage(roomId, message)); }, + category: CommandCategories.messages, }), ddg: new Command({ @@ -101,6 +129,7 @@ export const CommandMap = { }); return success(); }, + category: CommandCategories.actions, hideCompletionAfterSpace: true, }), @@ -110,8 +139,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: ( @@ -169,16 +203,17 @@ 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()); }, + category: CommandCategories.admin, }), nick: new Command({ @@ -191,6 +226,7 @@ export const CommandMap = { } return reject(this.getUsage()); }, + category: CommandCategories.actions, }), myroomnick: new Command({ @@ -209,6 +245,7 @@ export const CommandMap = { } return reject(this.getUsage()); }, + category: CommandCategories.actions, }), myroomavatar: new Command({ @@ -222,26 +259,11 @@ export const CommandMap = { let promise = Promise.resolve(args); if (!args) { - promise = new Promise((resolve) => { - const fileSelector = document.createElement('input'); - fileSelector.setAttribute('type', 'file'); - fileSelector.onchange = (ev) => { - const file = ev.target.files[0]; - - const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); - Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, { - file, - onFinished: (shouldContinue) => { - if (shouldContinue) resolve(cli.uploadContent(file)); - }, - }); - }; - - fileSelector.click(); - }); + promise = singleMxcUpload(); } return success(promise.then((url) => { + if (!url) return; const ev = room.currentState.getStateEvents('m.room.member', userId); const content = { ...ev ? ev.getContent() : { membership: 'join' }, @@ -250,31 +272,25 @@ export const CommandMap = { return cli.sendStateEvent(roomId, 'm.room.member', content, userId); })); }, + category: CommandCategories.actions, }), - tint: new Command({ - name: 'tint', - args: ' []', - description: _td('Changes colour scheme of current room'), + myavatar: new Command({ + name: 'myavatar', + args: '[]', + description: _td('Changes your avatar in all rooms'), runFn: function(roomId, args) { - if (args) { - const matches = args.match(/^(#([\da-fA-F]{3}|[\da-fA-F]{6}))( +(#([\da-fA-F]{3}|[\da-fA-F]{6})))?$/); - if (matches) { - Tinter.tint(matches[1], matches[4]); - const colorScheme = {}; - colorScheme.primary_color = matches[1]; - if (matches[4]) { - colorScheme.secondary_color = matches[4]; - } else { - colorScheme.secondary_color = colorScheme.primary_color; - } - return success( - SettingsStore.setValue('roomColor', roomId, SettingLevel.ROOM_ACCOUNT, colorScheme), - ); - } + let promise = Promise.resolve(args); + if (!args) { + promise = singleMxcUpload(); } - return reject(this.getUsage()); + + return success(promise.then((url) => { + if (!url) return; + return MatrixClientPeg.get().setAvatarUrl(url); + })); }, + category: CommandCategories.actions, }), topic: new Command({ @@ -300,6 +316,7 @@ export const CommandMap = { }); return success(); }, + category: CommandCategories.admin, }), roomname: new Command({ @@ -312,6 +329,7 @@ export const CommandMap = { } return reject(this.getUsage()); }, + category: CommandCategories.admin, }), invite: new Command({ @@ -335,6 +353,7 @@ export const CommandMap = { } return reject(this.getUsage()); }, + category: CommandCategories.actions, }), join: new Command({ @@ -380,8 +399,9 @@ export const CommandMap = { room_id: roomId, opts: { // These are passed down to the js-sdk's /join call - server_name: viaServers, + viaServers: viaServers, }, + via_servers: viaServers, // for the rejoin button auto_join: true, }); return success(); @@ -422,10 +442,14 @@ export const CommandMap = { } if (viaServers) { + // For the join dispatch["opts"] = { // These are passed down to the js-sdk's /join call - server_name: viaServers, + viaServers: viaServers, }; + + // For if the join fails (rejoin button) + dispatch['via_servers'] = viaServers; } dis.dispatch(dispatch); @@ -434,6 +458,7 @@ export const CommandMap = { } return reject(this.getUsage()); }, + category: CommandCategories.actions, }), part: new Command({ @@ -481,6 +506,7 @@ export const CommandMap = { }), ); }, + category: CommandCategories.actions, }), kick: new Command({ @@ -496,6 +522,7 @@ export const CommandMap = { } return reject(this.getUsage()); }, + category: CommandCategories.admin, }), // Ban a user from the room with an optional reason @@ -512,6 +539,7 @@ export const CommandMap = { } return reject(this.getUsage()); }, + category: CommandCategories.admin, }), // Unban a user from ythe room @@ -529,6 +557,7 @@ export const CommandMap = { } return reject(this.getUsage()); }, + category: CommandCategories.admin, }), ignore: new Command({ @@ -559,6 +588,7 @@ export const CommandMap = { } return reject(this.getUsage()); }, + category: CommandCategories.actions, }), unignore: new Command({ @@ -590,6 +620,7 @@ export const CommandMap = { } return reject(this.getUsage()); }, + category: CommandCategories.actions, }), // Define the power level of a user @@ -618,6 +649,7 @@ export const CommandMap = { } return reject(this.getUsage()); }, + category: CommandCategories.admin, }), // Reset the power level of a user @@ -639,6 +671,7 @@ export const CommandMap = { } return reject(this.getUsage()); }, + category: CommandCategories.admin, }), devtools: new Command({ @@ -649,6 +682,7 @@ export const CommandMap = { Modal.createDialog(DevtoolsDialog, {roomId}); return success(); }, + category: CommandCategories.advanced, }), addwidget: new Command({ @@ -669,6 +703,7 @@ export const CommandMap = { return reject(_t("You cannot modify widgets in this room.")); } }, + category: CommandCategories.admin, }), // Verify a user, device, and pubkey tuple @@ -738,6 +773,7 @@ export const CommandMap = { } return reject(this.getUsage()); }, + category: CommandCategories.advanced, }), // Command definitions for autocompletion ONLY: @@ -747,6 +783,7 @@ export const CommandMap = { name: 'me', args: '', description: _td('Displays action'), + category: CommandCategories.messages, hideCompletionAfterSpace: true, }), @@ -761,6 +798,7 @@ export const CommandMap = { } return success(); }, + category: CommandCategories.advanced, }), rainbow: new Command({ @@ -771,6 +809,7 @@ export const CommandMap = { if (!args) return reject(this.getUserId()); return success(MatrixClientPeg.get().sendHtmlMessage(roomId, args, textToHtmlRainbow(args))); }, + category: CommandCategories.messages, }), rainbowme: new Command({ @@ -781,6 +820,19 @@ export const CommandMap = { if (!args) return reject(this.getUserId()); return success(MatrixClientPeg.get().sendHtmlEmote(roomId, args, textToHtmlRainbow(args))); }, + category: CommandCategories.messages, + }), + + help: new Command({ + name: "help", + description: _td("Displays list of commands with usages and descriptions"), + runFn: function() { + const SlashCommandHelpDialog = sdk.getComponent('dialogs.SlashCommandHelpDialog'); + + Modal.createTrackedDialog('Slash Commands', 'Help', SlashCommandHelpDialog); + return success(); + }, + category: CommandCategories.advanced, }), }; /* eslint-enable babel/no-invalid-this */ diff --git a/src/ComposerHistoryManager.js b/src/SlateComposerHistoryManager.js similarity index 97% rename from src/ComposerHistoryManager.js rename to src/SlateComposerHistoryManager.js index ecf773f2e7..948dcf64ff 100644 --- a/src/ComposerHistoryManager.js +++ b/src/SlateComposerHistoryManager.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Value } from 'slate'; +import {Value} from 'slate'; import _clamp from 'lodash/clamp'; @@ -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 new file mode 100644 index 0000000000..02e34cbb3f --- /dev/null +++ b/src/Terms.js @@ -0,0 +1,177 @@ +/* +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 Promise from 'bluebird'; +import classNames from 'classnames'; + +import MatrixClientPeg from './MatrixClientPeg'; +import sdk from './'; +import Modal from './Modal'; + +export class TermsNotSignedError extends Error {} + +/** + * Class representing a service that may have terms & conditions that + * require agreement from the user before the user can use that service. + */ +export class Service { + /** + * @param {MatrixClient.SERVICE_TYPES} serviceType The type of service + * @param {string} baseUrl The Base URL of the service (ie. before '/_matrix') + * @param {string} accessToken The user's access token for the service + */ + constructor(serviceType, baseUrl, accessToken) { + this.serviceType = serviceType; + this.baseUrl = baseUrl; + this.accessToken = accessToken; + } +} + +/** + * Start a flow where the user is presented with terms & conditions for some services + * + * @param {Service[]} services Object with keys 'serviceType', 'baseUrl', 'accessToken' + * @param {function} interactionCallback Function called with: + * * an array of { service: {Service}, policies: {terms response from API} } + * * an array of URLs the user has already agreed to + * Must return a Promise which resolves with a list of URLs of documents agreed to + * @returns {Promise} resolves when the user agreed to all necessary terms or rejects + * if they cancel. + */ +export async function startTermsFlow( + services, + interactionCallback = dialogTermsInteractionCallback, +) { + const termsPromises = services.map( + (s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl), + ); + + /* + * a /terms response looks like: + * { + * "policies": { + * "terms_of_service": { + * "version": "2.0", + * "en": { + * "name": "Terms of Service", + * "url": "https://example.org/somewhere/terms-2.0-en.html" + * }, + * "fr": { + * "name": "Conditions d'utilisation", + * "url": "https://example.org/somewhere/terms-2.0-fr.html" + * } + * } + * } + * } + */ + + const terms = await Promise.all(termsPromises); + const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; }); + + // fetch the set of agreed policy URLs from account data + const currentAcceptedTerms = await MatrixClientPeg.get().getAccountData('m.accepted_terms'); + let agreedUrlSet; + if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) { + agreedUrlSet = new Set(); + } else { + agreedUrlSet = new Set(currentAcceptedTerms.getContent().accepted); + } + + // remove any policies the user has already agreed to and any services where + // they've already agreed to all the policies + // NB. it could be nicer to show the user stuff they've already agreed to, + // but then they'd assume they can un-check the boxes to un-agree to a policy, + // but that is not a thing the API supports, so probably best to just show + // things they've not agreed to yet. + const unagreedPoliciesAndServicePairs = []; + for (const {service, policies} of policiesAndServicePairs) { + const unagreedPolicies = {}; + for (const [policyName, policy] of Object.entries(policies)) { + let policyAgreed = false; + for (const lang of Object.keys(policy)) { + if (lang === 'version') continue; + if (agreedUrlSet.has(policy[lang].url)) { + policyAgreed = true; + break; + } + } + if (!policyAgreed) unagreedPolicies[policyName] = policy; + } + if (Object.keys(unagreedPolicies).length > 0) { + unagreedPoliciesAndServicePairs.push({service, policies: unagreedPolicies}); + } + } + + // if there's anything left to agree to, prompt the user + if (unagreedPoliciesAndServicePairs.length > 0) { + const newlyAgreedUrls = await interactionCallback(unagreedPoliciesAndServicePairs, [...agreedUrlSet]); + console.log("User has agreed to URLs", newlyAgreedUrls); + agreedUrlSet = new Set(newlyAgreedUrls); + } 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); + + const agreePromises = policiesAndServicePairs.map((policiesAndService) => { + // filter the agreed URL list for ones that are actually for this service + // (one URL may be used for multiple services) + // Not a particularly efficient loop but probably fine given the numbers involved + const urlsForService = Array.from(agreedUrlSet).filter((url) => { + for (const policy of Object.values(policiesAndService.policies)) { + for (const lang of Object.keys(policy)) { + if (lang === 'version') continue; + if (policy[lang].url === url) return true; + } + } + return false; + }); + + if (urlsForService.length === 0) return Promise.resolve(); + + return MatrixClientPeg.get().agreeToTerms( + policiesAndService.service.serviceType, + policiesAndService.service.baseUrl, + policiesAndService.service.accessToken, + urlsForService, + ); + }); + return Promise.all(agreePromises); +} + +export function dialogTermsInteractionCallback( + policiesAndServicePairs, + agreedUrls, + extraClassNames, +) { + return new Promise((resolve, reject) => { + console.log("Terms that need agreement", policiesAndServicePairs); + const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); + + Modal.createTrackedDialog('Terms of Service', '', TermsDialog, { + policiesAndServicePairs, + agreedUrls, + onFinished: (done, agreedUrls) => { + if (!done) { + reject(new TermsNotSignedError()); + return; + } + resolve(agreedUrls); + }, + }, classNames("mx_TermsDialog", extraClassNames)); + }); +} diff --git a/src/TextForEvent.js b/src/TextForEvent.js index a700fe2a3c..e3c249df3f 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -18,6 +18,7 @@ import CallHandler from './CallHandler'; import { _t } from './languageHandler'; import * as Roles from './Roles'; import {isValid3pidInvite} from "./RoomInvite"; +import SettingsStore from "./settings/SettingsStore"; function textForMemberEvent(ev) { // XXX: SYJS-16 "sender is sometimes null for join messages" @@ -74,9 +75,11 @@ function textForMemberEvent(ev) { return _t('%(senderName)s changed their profile picture.', {senderName}); } else if (!prevContent.avatar_url && content.avatar_url) { return _t('%(senderName)s set a profile picture.', {senderName}); + } else if (SettingsStore.getValue("showHiddenEventsInTimeline")) { + // This is a null rejoin, it will only be visible if the Labs option is enabled + return _t("%(senderName)s made no change.", {senderName}); } else { - // suppress null rejoins - return ''; + return ""; } } else { if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); @@ -97,15 +100,14 @@ function textForMemberEvent(ev) { } } else if (prevContent.membership === "ban") { return _t('%(senderName)s unbanned %(targetName)s.', {senderName, targetName}); - } else if (prevContent.membership === "join") { - return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason; } else if (prevContent.membership === "invite") { return _t('%(senderName)s withdrew %(targetName)s\'s invitation.', { senderName, targetName, }) + ' ' + reason; } else { - return _t('%(targetName)s left the room.', {targetName}); + // sender is not target and made the target leave, if not from invite/ban then this is a kick + return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason; } } } @@ -414,7 +416,8 @@ function textForEncryptionEvent(event) { // Currently will only display a change if a user's power level is changed function textForPowerEvent(event) { const senderName = event.sender ? event.sender.name : event.getSender(); - if (!event.getPrevContent() || !event.getPrevContent().users) { + if (!event.getPrevContent() || !event.getPrevContent().users || + !event.getContent() || !event.getContent().users) { return ''; } const userDefault = event.getContent().users_default || 0; diff --git a/src/Unread.js b/src/Unread.js index 9514ec821b..ce6784dc58 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -70,20 +70,20 @@ module.exports = { const ev = room.timeline[i]; if (ev.getId() == readUpToId) { - // If we've read up to this event, there's nothing more recents + // If we've read up to this event, there's nothing more recent // that counts and we can stop looking because the user's read // this and everything before. return false; } else if (!shouldHideEvent(ev) && this.eventTriggersUnreadCount(ev)) { // We've found a message that counts before we hit - // the read marker, so this room is definitely unread. + // the user's read receipt, so this room is definitely unread. return true; } } - // If we got here, we didn't find a message that counted but didn't - // find the read marker either, so we guess and say that the room - // is unread on the theory that false positives are better than - // false negatives here. + // If we got here, we didn't find a message that counted but didn't find + // the user's read receipt either, so we guess and say that the room is + // unread on the theory that false positives are better than false + // negatives here. return true; }, }; diff --git a/src/Velociraptor.js b/src/Velociraptor.js index d2cae5c2a7..b7a2d7fb40 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -1,6 +1,7 @@ const React = require('react'); const ReactDom = require('react-dom'); import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; const Velocity = require('velocity-animate'); /** @@ -10,7 +11,7 @@ const Velocity = require('velocity-animate'); * from DOM order. This makes it a lot simpler and lighter: if you need fully * automatic positional animation, look at react-shuffle or similar libraries. */ -module.exports = React.createClass({ +module.exports = createReactClass({ displayName: 'Velociraptor', propTypes: { 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 06fb0668d5..0fd412935a 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -17,20 +17,21 @@ 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 * as Matrix from 'matrix-js-sdk'; +import { MatrixClient } from 'matrix-js-sdk'; import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; import sdk from '../../../index'; const PHASE_EDIT = 1; const PHASE_EXPORTING = 2; -export default React.createClass({ +export default createReactClass({ displayName: 'ExportE2eKeysDialog', propTypes: { - matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired, + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, onFinished: PropTypes.func.isRequired, }, diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js index 10744a8911..17f3bba117 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -16,8 +16,9 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; -import * as Matrix from 'matrix-js-sdk'; +import { MatrixClient } from 'matrix-js-sdk'; import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; @@ -37,11 +38,11 @@ function readFileAsArrayBuffer(file) { const PHASE_EDIT = 1; const PHASE_IMPORTING = 2; -export default React.createClass({ +export default createReactClass({ displayName: 'ImportE2eKeysDialog', propTypes: { - matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired, + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, onFinished: PropTypes.func.isRequired, }, 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..799728f801 --- /dev/null +++ b/src/boundThreepids.js @@ -0,0 +1,52 @@ +/* +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 getThreepidBindStatus(client, filterMedium) { + const userId = client.getUserId(); + + let { threepids } = await client.getThreePids(); + if (filterMedium) { + threepids = threepids.filter((a) => a.medium === filterMedium); + } + + if (threepids.length > 0) { + // TODO: Handle terms agreement + // See https://github.com/vector-im/riot-web/issues/10522 + const authClient = new IdentityAuthClient(); + const identityAccessToken = await authClient.getAccessToken(); + + // 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; + } + } + + return threepids; +} diff --git a/src/components/structures/CompatibilityPage.js b/src/components/structures/CompatibilityPage.js index c6abba4eb3..9241f9e1f4 100644 --- a/src/components/structures/CompatibilityPage.js +++ b/src/components/structures/CompatibilityPage.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket 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. @@ -14,15 +15,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - -const React = require('react'); +import React from 'react'; +import PropTypes from 'prop-types'; import { _t } from '../../languageHandler'; module.exports = React.createClass({ displayName: 'CompatibilityPage', propTypes: { - onAccept: React.PropTypes.func, + onAccept: PropTypes.func, }, getDefaultProps: function() { diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js index 345eae2b18..3ce52247d9 100644 --- a/src/components/structures/ContextualMenu.js +++ b/src/components/structures/ContextualMenu.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 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. @@ -15,7 +16,6 @@ See the License for the specific language governing permissions and limitations under the License. */ - import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; @@ -48,7 +48,6 @@ export default class ContextualMenu extends React.Component { menuWidth: PropTypes.number, menuHeight: PropTypes.number, chevronOffset: PropTypes.number, - menuColour: PropTypes.string, chevronFace: PropTypes.string, // top, bottom, left, right or none // Function to be called on menu close onFinished: PropTypes.func, @@ -157,25 +156,6 @@ export default class ContextualMenu extends React.Component { chevronOffset.top = Math.max(props.chevronOffset, props.chevronOffset + target - adjusted); } - // To override the default chevron colour, if it's been set - let chevronCSS = ""; - if (props.menuColour) { - chevronCSS = ` - .mx_ContextualMenu_chevron_left:after { - border-right-color: ${props.menuColour}; - } - .mx_ContextualMenu_chevron_right:after { - border-left-color: ${props.menuColour}; - } - .mx_ContextualMenu_chevron_top:after { - border-left-color: ${props.menuColour}; - } - .mx_ContextualMenu_chevron_bottom:after { - border-left-color: ${props.menuColour}; - } - `; - } - const chevron = hasChevron ?
: undefined; @@ -183,11 +163,14 @@ export default class ContextualMenu extends React.Component { const menuClasses = classNames({ 'mx_ContextualMenu': true, - 'mx_ContextualMenu_noChevron': chevronFace === 'none', - 'mx_ContextualMenu_left': chevronFace === 'left', - 'mx_ContextualMenu_right': chevronFace === 'right', - 'mx_ContextualMenu_top': chevronFace === 'top', - 'mx_ContextualMenu_bottom': chevronFace === 'bottom', + 'mx_ContextualMenu_left': !hasChevron && position.left, + 'mx_ContextualMenu_right': !hasChevron && position.right, + 'mx_ContextualMenu_top': !hasChevron && position.top, + 'mx_ContextualMenu_bottom': !hasChevron && position.bottom, + 'mx_ContextualMenu_withChevron_left': chevronFace === 'left', + 'mx_ContextualMenu_withChevron_right': chevronFace === 'right', + 'mx_ContextualMenu_withChevron_top': chevronFace === 'top', + 'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom', }); const menuStyle = {}; @@ -199,10 +182,6 @@ export default class ContextualMenu extends React.Component { menuStyle.height = props.menuHeight; } - if (props.menuColour) { - menuStyle["backgroundColor"] = props.menuColour; - } - if (!isNaN(Number(props.menuPaddingTop))) { menuStyle["paddingTop"] = props.menuPaddingTop; } @@ -233,7 +212,6 @@ export default class ContextualMenu extends React.Component {
{ props.hasBackground &&
} -
; } } diff --git a/src/components/structures/EmbeddedPage.js b/src/components/structures/EmbeddedPage.js index d10b7f8414..ecc01a443d 100644 --- a/src/components/structures/EmbeddedPage.js +++ b/src/components/structures/EmbeddedPage.js @@ -24,6 +24,8 @@ import request from 'browser-request'; import { _t } from '../../languageHandler'; import sanitizeHtml from 'sanitize-html'; import sdk from '../../index'; +import dis from '../../dispatcher'; +import MatrixClientPeg from '../../MatrixClientPeg'; import { MatrixClient } from 'matrix-js-sdk'; import classnames from 'classnames'; @@ -44,6 +46,8 @@ export default class EmbeddedPage extends React.PureComponent { constructor(props) { super(props); + this._dispatcherRef = null; + this.state = { page: '', }; @@ -82,19 +86,31 @@ export default class EmbeddedPage extends React.PureComponent { this.setState({ page: body }); }, ); + + this._dispatcherRef = dis.register(this.onAction); } componentWillUnmount() { this._unmounted = true; + if (this._dispatcherRef !== null) dis.unregister(this._dispatcherRef); } + onAction = (payload) => { + // HACK: Workaround for the context's MatrixClient not being set up at render time. + if (payload.action === 'client_started') { + this.forceUpdate(); + } + }; + render() { - const client = this.context.matrixClient; + // HACK: Workaround for the context's MatrixClient not updating. + const client = this.context.matrixClient || MatrixClientPeg.get(); const isGuest = client ? client.isGuest() : true; const className = this.props.className; const classes = classnames({ [className]: true, [`${className}_guest`]: isGuest, + [`${className}_loggedIn`]: !!client, }); const content =
-

{_t("Error loading Riot")}

+

{this.props.title}

{this.props.message}

-

{_t( - "If this is unexpected, please contact your system administrator " + - "or technical support representative.", - )}

; } diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index cdfbe26fea..d5fa8fa5ae 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -1,6 +1,7 @@ /* Copyright 2017 Vector Creations 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. @@ -342,7 +343,6 @@ const FeaturedUser = React.createClass({ dis.dispatch({ action: 'view_start_chat_or_reuse', user_id: this.props.summaryInfo.user_id, - go_home_on_cancel: false, }); }, @@ -861,9 +861,9 @@ export default React.createClass({ const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const TintableSvg = sdk.getComponent('elements.TintableSvg'); const Spinner = sdk.getComponent('elements.Spinner'); - const ToolTipButton = sdk.getComponent('elements.ToolTipButton'); + const TooltipButton = sdk.getComponent('elements.TooltipButton'); - const roomsHelpNode = this.state.editing ? 0) { + this._likelyTrackpadUser = true; + this._checkAgainForTrackpad = now + (1 * 60 * 1000); + } else { + // if we haven't seen any horizontal scrolling for a while, assume + // the user might have plugged in a mousewheel + if (this._likelyTrackpadUser && now >= this._checkAgainForTrackpad) { + this._likelyTrackpadUser = false; + } + } + + // don't mess with the horizontal scroll for trackpad users + // See https://github.com/vector-im/riot-web/issues/10005 + if (this._likelyTrackpadUser) { + return; + } + + if (Math.abs(e.deltaX) <= xyThreshold) { // we are vertically scrolling. + // HACK: We increase the amount of scroll to counteract smooth scrolling browsers. + // Smooth scrolling browsers (Firefox) use the relative area to determine the scroll + // amount, which means the likely small area of content results in a small amount of + // movement - not what people expect. We pick arbitrary values for when to apply more + // scroll, and how much to apply. On Windows 10, Chrome scrolls 100 units whereas + // Firefox scrolls just 3 due to smooth scrolling. + + const additionalScroll = e.deltaY < 0 ? -50 : 50; + // noinspection JSSuspiciousNameCombination - this._scrollElement.scrollLeft += e.deltaY * yRetention; + const val = Math.abs(e.deltaY) < 25 ? (e.deltaY + additionalScroll) : e.deltaY; + this._scrollElement.scrollLeft += val * yRetention; } } }; diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index c893057022..ccc906601c 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -23,6 +23,8 @@ import PropTypes from 'prop-types'; import {getEntryComponentForLoginType} from '../views/auth/InteractiveAuthEntryComponents'; +import sdk from '../../index'; + export default React.createClass({ displayName: 'InteractiveAuth', @@ -91,13 +93,14 @@ export default React.createClass({ this._authLogic = new InteractiveAuth({ authData: this.props.authData, doRequest: this._requestCallback, + busyChanged: this._onBusyChanged, inputs: this.props.inputs, stateUpdated: this._authStateUpdated, matrixClient: this.props.matrixClient, sessionId: this.props.sessionId, clientSecret: this.props.clientSecret, emailSid: this.props.emailSid, - requestEmailToken: this.props.requestEmailToken, + requestEmailToken: this._requestEmailToken, }); this._authLogic.attemptAuth().then((result) => { @@ -135,6 +138,19 @@ export default React.createClass({ } }, + _requestEmailToken: async function(...args) { + this.setState({ + busy: true, + }); + try { + return await this.props.requestEmailToken(...args); + } finally { + this.setState({ + busy: false, + }); + } + }, + tryContinue: function() { if (this.refs.stageComponent && this.refs.stageComponent.tryContinue) { this.refs.stageComponent.tryContinue(); @@ -152,27 +168,26 @@ export default React.createClass({ }); }, - _requestCallback: function(auth, background) { - const makeRequestPromise = this.props.makeRequest(auth); + _requestCallback: function(auth) { + // This wrapper just exists because the js-sdk passes a second + // 'busy' param for backwards compat. This throws the tests off + // so discard it here. + return this.props.makeRequest(auth); + }, - // if it's a background request, just do it: we don't want - // it to affect the state of our UI. - if (background) return makeRequestPromise; - - // otherwise, manage the state of the spinner and error messages - this.setState({ - busy: true, - errorText: null, - stageErrorText: null, - }); - return makeRequestPromise.finally(() => { - if (this._unmounted) { - return; - } + _onBusyChanged: function(busy) { + // if we've started doing stuff, reset the error messages + if (busy) { + this.setState({ + busy: true, + errorText: null, + stageErrorText: null, + }); + } else { this.setState({ busy: false, }); - }); + } }, _setFocus: function() { @@ -187,7 +202,14 @@ export default React.createClass({ _renderCurrentStage: function() { const stage = this.state.authStage; - if (!stage) return null; + if (!stage) { + if (this.state.busy) { + const Loader = sdk.getComponent("elements.Spinner"); + return ; + } else { + return null; + } + } const StageComponent = getEntryComponentForLoginType(stage); return ( diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index 7a16db1c6a..2581319d75 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -54,9 +54,9 @@ const LeftPanel = React.createClass({ this.focusedElement = null; this._settingWatchRef = SettingsStore.watchSetting( - "feature_room_breadcrumbs", null, this._onBreadcrumbsChanged); + "breadcrumbs", null, this._onBreadcrumbsChanged); - const useBreadcrumbs = SettingsStore.isFeatureEnabled("feature_room_breadcrumbs"); + const useBreadcrumbs = !!SettingsStore.getValue("breadcrumbs"); Analytics.setBreadcrumbs(useBreadcrumbs); this.setState({breadcrumbs: useBreadcrumbs}); }, diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 7a5c6e3006..17c8e91cb9 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as Matrix from 'matrix-js-sdk'; +import { MatrixClient } from 'matrix-js-sdk'; import React from 'react'; import PropTypes from 'prop-types'; import { DragDropContext } from 'react-beautiful-dnd'; @@ -42,6 +42,13 @@ import {Resizer, CollapseDistributor} from '../../resizer'; // NB. this is just for server notices rather than pinned messages in general. const MAX_PINNED_NOTICES_PER_ROOM = 2; +function canElementReceiveInput(el) { + return el.tagName === "INPUT" || + el.tagName === "TEXTAREA" || + el.tagName === "SELECT" || + !!el.getAttribute("contenteditable"); +} + /** * This is what our MatrixChat shows when we are logged in. The precise view is * determined by the page_type property. @@ -55,7 +62,7 @@ const LoggedInView = React.createClass({ displayName: 'LoggedInView', propTypes: { - matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired, + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, page_type: PropTypes.string.isRequired, onRoomCreated: PropTypes.func, @@ -71,7 +78,7 @@ const LoggedInView = React.createClass({ }, childContextTypes: { - matrixClient: PropTypes.instanceOf(Matrix.MatrixClient), + matrixClient: PropTypes.instanceOf(MatrixClient), authCache: PropTypes.object, }, @@ -106,7 +113,7 @@ const LoggedInView = React.createClass({ CallMediaHandler.loadDevices(); - document.addEventListener('keydown', this._onKeyDown); + document.addEventListener('keydown', this._onNativeKeyDown, false); this._sessionStore = sessionStore; this._sessionStoreToken = this._sessionStore.addListener( @@ -136,7 +143,7 @@ const LoggedInView = React.createClass({ }, componentWillUnmount: function() { - document.removeEventListener('keydown', this._onKeyDown); + document.removeEventListener('keydown', this._onNativeKeyDown, false); this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); @@ -272,6 +279,58 @@ const LoggedInView = React.createClass({ }); }, + _onPaste: function(ev) { + let canReceiveInput = false; + let element = ev.target; + // test for all parents because the target can be a child of a contenteditable element + while (!canReceiveInput && element) { + canReceiveInput = canElementReceiveInput(element); + element = element.parentElement; + } + if (!canReceiveInput) { + // refocusing during a paste event will make the + // paste end up in the newly focused element, + // so dispatch synchronously before paste happens + dis.dispatch({action: 'focus_composer'}, true); + } + }, + + /* + SOME HACKERY BELOW: + React optimizes event handlers, by always attaching only 1 handler to the document for a given type. + It then internally determines the order in which React event handlers should be called, + emulating the capture and bubbling phases the DOM also has. + + But, as the native handler for React is always attached on the document, + it will always run last for bubbling (first for capturing) handlers, + and thus React basically has its own event phases, and will always run + after (before for capturing) any native other event handlers (as they tend to be attached last). + + So ideally one wouldn't mix React and native event handlers to have bubbling working as expected, + but we do need a native event handler here on the document, + to get keydown events when there is no focused element (target=body). + + We also do need bubbling here to give child components a chance to call `stopPropagation()`, + for keydown events it can handle itself, and shouldn't be redirected to the composer. + + So we listen with React on this component to get any events on focused elements, and get bubbling working as expected. + We also listen with a native listener on the document to get keydown events when no element is focused. + Bubbling is irrelevant here as the target is the body element. + */ + _onReactKeyDown: function(ev) { + // events caught while bubbling up on the root element + // of this component, so something must be focused. + this._onKeyDown(ev); + }, + + _onNativeKeyDown: function(ev) { + // only pass this if there is no focused element. + // if there is, _onKeyDown will be called by the + // react keydown handler that respects the react bubbling order. + if (ev.target === document.body) { + this._onKeyDown(ev); + } + }, _onKeyDown: function(ev) { /* @@ -290,21 +349,13 @@ const LoggedInView = React.createClass({ let handled = false; const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); + const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey || + ev.key === "Alt" || ev.key === "Control" || ev.key === "Meta" || ev.key === "Shift"; switch (ev.keyCode) { - case KeyCode.UP: - case KeyCode.DOWN: - if (ev.altKey && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) { - const action = ev.keyCode == KeyCode.UP ? - 'view_prev_room' : 'view_next_room'; - dis.dispatch({action: action}); - handled = true; - } - break; - case KeyCode.PAGE_UP: case KeyCode.PAGE_DOWN: - if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { + if (!hasModifier) { this._onScrollKeyPressed(ev); handled = true; } @@ -325,10 +376,11 @@ const LoggedInView = React.createClass({ handled = true; } break; - case KeyCode.KEY_I: + case KeyCode.KEY_BACKTICK: // Ideally this would be CTRL+P for "Profile", but that's // taken by the print dialog. CTRL+I for "Information" - // will have to do. + // was previously chosen but conflicted with italics in + // composer, so CTRL+` it is if (ctrlCmdOnly) { dis.dispatch({ @@ -342,6 +394,17 @@ const LoggedInView = React.createClass({ if (handled) { ev.stopPropagation(); ev.preventDefault(); + } else if (!hasModifier) { + const isClickShortcut = ev.target !== document.body && + (ev.key === "Space" || ev.key === "Enter"); + + if (!isClickShortcut && !canElementReceiveInput(ev.target)) { + // synchronous dispatch so we focus before key generates input + dis.dispatch({action: 'focus_composer'}, true); + ev.stopPropagation(); + // we should *not* preventDefault() here as + // that would prevent typing in the now-focussed composer + } } }, @@ -553,7 +616,7 @@ const LoggedInView = React.createClass({ } return ( -
+
{ topBar }
diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 0b52cfa1bc..aeffff9717 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -2,6 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2017-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. @@ -50,8 +51,10 @@ import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import { startAnyRegistrationFlow } from "../../Registration.js"; import { messageForSyncError } from '../../utils/ErrorUtils'; import ResizeNotifier from "../../utils/ResizeNotifier"; - -const AutoDiscovery = Matrix.AutoDiscovery; +import { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils"; +import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils"; +import DMRoomMap from '../../utils/DMRoomMap'; +import { countRoomsWithNotif } from '../../RoomNotifs'; // Disable warnings for now: we use deprecated bluebird functions // and need to migrate, but they spam the console with warnings. @@ -87,6 +90,10 @@ const VIEWS = { // we are logged in with an active matrix client. LOGGED_IN: 7, + + // We are logged out (invalid token) but have our local state again. The user + // should log back in to rehydrate the client. + SOFT_LOGOUT: 8, }; // Actions that are redirected through the onboarding process prior to being @@ -109,6 +116,7 @@ export default React.createClass({ propTypes: { config: PropTypes.object, + serverConfig: PropTypes.instanceOf(ValidatedServerConfig), ConferenceHandler: PropTypes.any, onNewScreen: PropTypes.func, registrationUrl: PropTypes.string, @@ -181,16 +189,8 @@ export default React.createClass({ // Parameters used in the registration dance with the IS register_client_secret: null, register_session_id: null, - register_hs_url: null, - register_is_url: null, register_id_sid: null, - // Parameters used for setting up the authentication views - defaultServerName: this.props.config.default_server_name, - defaultHsUrl: this.props.config.default_hs_url, - defaultIsUrl: this.props.config.default_is_url, - defaultServerDiscoveryError: null, - // When showing Modal dialogs we need to set aria-hidden on the root app element // and disable it when there are no dialogs hideToSRUsers: false, @@ -211,42 +211,19 @@ export default React.createClass({ }; }, - getDefaultServerName: function() { - return this.state.defaultServerName; - }, - - getCurrentHsUrl: function() { - if (this.state.register_hs_url) { - return this.state.register_hs_url; - } else if (MatrixClientPeg.get()) { - return MatrixClientPeg.get().getHomeserverUrl(); - } else { - return this.getDefaultHsUrl(); - } - }, - - getDefaultHsUrl(defaultToMatrixDotOrg) { - defaultToMatrixDotOrg = typeof(defaultToMatrixDotOrg) !== 'boolean' ? true : defaultToMatrixDotOrg; - if (!this.state.defaultHsUrl && defaultToMatrixDotOrg) return "https://matrix.org"; - return this.state.defaultHsUrl; - }, - getFallbackHsUrl: function() { - return this.props.config.fallback_hs_url; - }, - - getCurrentIsUrl: function() { - if (this.state.register_is_url) { - return this.state.register_is_url; - } else if (MatrixClientPeg.get()) { - return MatrixClientPeg.get().getIdentityServerUrl(); + if (this.props.serverConfig && this.props.serverConfig.isDefault) { + return this.props.config.fallback_hs_url; } else { - return this.getDefaultIsUrl(); + return null; } }, - getDefaultIsUrl() { - return this.state.defaultIsUrl || "https://vector.im"; + getServerProperties() { + let props = this.state.serverConfig; + if (!props) props = this.props.serverConfig; // for unit tests + if (!props) props = SdkConfig.get()["validated_server_config"]; + return {serverConfig: props}; }, componentWillMount: function() { @@ -260,40 +237,6 @@ export default React.createClass({ MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit; } - // Set up the default URLs (async) - if (this.getDefaultServerName() && !this.getDefaultHsUrl(false)) { - this.setState({loadingDefaultHomeserver: true}); - this._tryDiscoverDefaultHomeserver(this.getDefaultServerName()); - } else if (this.getDefaultServerName() && this.getDefaultHsUrl(false)) { - // Ideally we would somehow only communicate this to the server admins, but - // given this is at login time we can't really do much besides hope that people - // will check their settings. - this.setState({ - defaultServerName: null, // To un-hide any secrets people might be keeping - defaultServerDiscoveryError: _t( - "Invalid configuration: Cannot supply a default homeserver URL and " + - "a default server name", - ), - }); - } - - // Set a default HS with query param `hs_url` - const paramHs = this.props.startingFragmentQueryParams.hs_url; - if (paramHs) { - console.log('Setting register_hs_url ', paramHs); - this.setState({ - register_hs_url: paramHs, - }); - } - // Set a default IS with query param `is_url` - const paramIs = this.props.startingFragmentQueryParams.is_url; - if (paramIs) { - console.log('Setting register_is_url ', paramIs); - this.setState({ - register_is_url: paramIs, - }); - } - // a thing to call showScreen with once login completes. this is kept // outside this.state because updating it should never trigger a // rerender. @@ -312,6 +255,14 @@ export default React.createClass({ // For PersistentElement this.state.resizeNotifier.on("middlePanelResized", this._dispatchTimelineResize); + + // Force users to go through the soft logout page if they're soft logged out + if (Lifecycle.isSoftLogout()) { + // When the session loads it'll be detected as soft logged out and a dispatch + // will be sent out to say that, triggering this MatrixChat to show the soft + // logout page. + Lifecycle.loadSession({}); + } }, componentDidMount: function() { @@ -332,29 +283,32 @@ export default React.createClass({ } // the first thing to do is to try the token params in the query-string - Lifecycle.attemptTokenLogin(this.props.realQueryParams).then((loggedIn) => { - if (loggedIn) { - this.props.onTokenLoginCompleted(); + // if the session isn't soft logged out (ie: is a clean session being logged in) + if (!Lifecycle.isSoftLogout()) { + Lifecycle.attemptTokenLogin(this.props.realQueryParams).then((loggedIn) => { + if (loggedIn) { + this.props.onTokenLoginCompleted(); - // don't do anything else until the page reloads - just stay in - // the 'loading' state. - return; - } + // don't do anything else until the page reloads - just stay in + // the 'loading' state. + return; + } - // if the user has followed a login or register link, don't reanimate - // the old creds, but rather go straight to the relevant page - const firstScreen = this._screenAfterLogin ? - this._screenAfterLogin.screen : null; + // if the user has followed a login or register link, don't reanimate + // the old creds, but rather go straight to the relevant page + const firstScreen = this._screenAfterLogin ? + this._screenAfterLogin.screen : null; - if (firstScreen === 'login' || + if (firstScreen === 'login' || firstScreen === 'register' || firstScreen === 'forgot_password') { - this._showScreenAfterLogin(); - return; - } + this._showScreenAfterLogin(); + return; + } - return this._loadSession(); - }); + return this._loadSession(); + }); + } if (SettingsStore.getValue("showCookieBar")) { this.setState({ @@ -374,8 +328,8 @@ export default React.createClass({ return Lifecycle.loadSession({ fragmentQueryParams: this.props.startingFragmentQueryParams, enableGuest: this.props.enableGuest, - guestHsUrl: this.getCurrentHsUrl(), - guestIsUrl: this.getCurrentIsUrl(), + guestHsUrl: this.getServerProperties().serverConfig.hsUrl, + guestIsUrl: this.getServerProperties().serverConfig.isUrl, defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, }); }).then((loadedSession) => { @@ -492,6 +446,29 @@ export default React.createClass({ } switch (payload.action) { + case 'MatrixActions.accountData': + // XXX: This is a collection of several hacks to solve a minor problem. We want to + // update our local state when the ID server changes, but don't want to put that in + // the js-sdk as we'd be then dictating how all consumers need to behave. However, + // this component is already bloated and we probably don't want this tiny logic in + // here, but there's no better place in the react-sdk for it. Additionally, we're + // abusing the MatrixActionCreator stuff to avoid errors on dispatches. + if (payload.event_type === 'm.identity_server') { + const fullUrl = payload.event_content ? payload.event_content['base_url'] : null; + if (!fullUrl) { + MatrixClientPeg.get().setIdentityServerUrl(null); + localStorage.removeItem("mx_is_access_token"); + localStorage.removeItem("mx_is_url"); + } else { + MatrixClientPeg.get().setIdentityServerUrl(fullUrl); + localStorage.removeItem("mx_is_access_token"); // clear token + localStorage.setItem("mx_is_url", fullUrl); // XXX: Do we still need this? + } + + // redispatch the change with a more specific action + dis.dispatch({action: 'id_server_changed'}); + } + break; case 'logout': Lifecycle.logout(); break; @@ -499,10 +476,24 @@ export default React.createClass({ startAnyRegistrationFlow(payload); break; case 'start_registration': + if (Lifecycle.isSoftLogout()) { + this._onSoftLogout(); + break; + } // This starts the full registration flow + if (payload.screenAfterLogin) { + this._screenAfterLogin = payload.screenAfterLogin; + } this._startRegistration(payload.params || {}); break; case 'start_login': + if (Lifecycle.isSoftLogout()) { + this._onSoftLogout(); + break; + } + if (payload.screenAfterLogin) { + this._screenAfterLogin = payload.screenAfterLogin; + } this.setStateForNewView({ view: VIEWS.LOGIN, }); @@ -616,7 +607,7 @@ export default React.createClass({ this._setMxId(payload); break; case 'view_start_chat_or_reuse': - this._chatCreateOrReuse(payload.user_id, payload.go_home_on_cancel); + this._chatCreateOrReuse(payload.user_id); break; case 'view_create_chat': showStartChatInviteDialog(); @@ -670,7 +661,12 @@ export default React.createClass({ }); break; case 'on_logged_in': - this._onLoggedIn(); + if (!Lifecycle.isSoftLogout()) { + this._onLoggedIn(); + } + break; + case 'on_client_not_viable': + this._onSoftLogout(); break; case 'on_logged_out': this._onLoggedOut(); @@ -734,7 +730,7 @@ export default React.createClass({ }); }, - _startRegistration: function(params) { + _startRegistration: async function(params) { const newState = { view: VIEWS.REGISTER, }; @@ -747,10 +743,12 @@ export default React.createClass({ params.is_url && params.sid ) { + newState.serverConfig = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( + params.hs_url, params.is_url, + ); + newState.register_client_secret = params.client_secret; newState.register_session_id = params.session_id; - newState.register_hs_url = params.hs_url; - newState.register_is_url = params.is_url; newState.register_id_sid = params.sid; } @@ -942,6 +940,7 @@ export default React.createClass({ } return; } + MatrixClientPeg.setJustRegisteredUserId(credentials.user_id); this.onRegistered(credentials); }, onDifferentServerClicked: (ev) => { @@ -955,26 +954,20 @@ export default React.createClass({ }).close; }, - _createRoom: function() { + _createRoom: async function() { const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog'); - Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { - onFinished: (shouldCreate, name, noFederate) => { - if (shouldCreate) { - const createOpts = {}; - if (name) createOpts.name = name; - if (noFederate) createOpts.creation_content = {'m.federate': false}; - createRoom({createOpts}).done(); - } - }, - }); + const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog); + + const [shouldCreate, name, noFederate] = await modal.finished; + if (shouldCreate) { + const createOpts = {}; + if (name) createOpts.name = name; + if (noFederate) createOpts.creation_content = {'m.federate': false}; + createRoom({createOpts}).done(); + } }, - _chatCreateOrReuse: function(userId, goHomeOnCancel) { - if (goHomeOnCancel === undefined) goHomeOnCancel = true; - - const ChatCreateOrReuseDialog = sdk.getComponent( - 'views.dialogs.ChatCreateOrReuseDialog', - ); + _chatCreateOrReuse: function(userId) { // Use a deferred action to reshow the dialog once the user has registered if (MatrixClientPeg.get().isGuest()) { // No point in making 2 DMs with welcome bot. This assumes view_set_mxid will @@ -999,30 +992,23 @@ export default React.createClass({ return; } - const close = Modal.createTrackedDialog('Chat create or reuse', '', ChatCreateOrReuseDialog, { - userId: userId, - onFinished: (success) => { - if (!success && goHomeOnCancel) { - // Dialog cancelled, default to home - dis.dispatch({ action: 'view_home_page' }); - } - }, - onNewDMClick: () => { - dis.dispatch({ - action: 'start_chat', - user_id: userId, - }); - // Close the dialog, indicate success (calls onFinished(true)) - close(true); - }, - onExistingRoomSelected: (roomId) => { - dis.dispatch({ - action: 'view_room', - room_id: roomId, - }); - close(true); - }, - }).close; + // TODO: Immutable DMs replaces this + + const client = MatrixClientPeg.get(); + const dmRoomMap = new DMRoomMap(client); + const dmRooms = dmRoomMap.getDMRoomsForUserId(userId); + + if (dmRooms.length > 0) { + dis.dispatch({ + action: 'view_room', + room_id: dmRooms[0], + }); + } else { + dis.dispatch({ + action: 'start_chat', + user_id: userId, + }); + } }, _leaveRoomWarnings: function(roomId) { @@ -1186,29 +1172,81 @@ export default React.createClass({ } }, + /** + * Starts a chat with the welcome user, if the user doesn't already have one + * @returns {string} The room ID of the new room, or null if no room was created + */ + async _startWelcomeUserChat() { + // We can end up with multiple tabs post-registration where the user + // might then end up with a session and we don't want them all making + // a chat with the welcome user: try to de-dupe. + // We need to wait for the first sync to complete for this to + // work though. + let waitFor; + if (!this.firstSyncComplete) { + waitFor = this.firstSyncPromise.promise; + } else { + waitFor = Promise.resolve(); + } + await waitFor; + + const welcomeUserRooms = DMRoomMap.shared().getDMRoomsForUserId( + this.props.config.welcomeUserId, + ); + if (welcomeUserRooms.length === 0) { + const roomId = await createRoom({ + dmUserId: this.props.config.welcomeUserId, + // Only view the welcome user if we're NOT looking at a room + andView: !this.state.currentRoomId, + spinner: false, // we're already showing one: we don't need another one + }); + // This is a bit of a hack, but since the deduplication relies + // on m.direct being up to date, we need to force a sync + // of the database, otherwise if the user goes to the other + // tab before the next save happens (a few minutes), the + // saved sync will be restored from the db and this code will + // run without the update to m.direct, making another welcome + // user room (it doesn't wait for new data from the server, just + // the saved sync to be loaded). + const saveWelcomeUser = (ev) => { + if ( + ev.getType() == 'm.direct' && + ev.getContent() && + ev.getContent()[this.props.config.welcomeUserId] + ) { + MatrixClientPeg.get().store.save(true); + MatrixClientPeg.get().removeListener( + "accountData", saveWelcomeUser, + ); + } + }; + MatrixClientPeg.get().on("accountData", saveWelcomeUser); + + return roomId; + } + return null; + }, + /** * Called when a new logged in session has started */ _onLoggedIn: async function() { this.setStateForNewView({ view: VIEWS.LOGGED_IN }); - if (this._is_registered) { - this._is_registered = false; + if (MatrixClientPeg.currentUserIsJustRegistered()) { + MatrixClientPeg.setJustRegisteredUserId(null); if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) { - const roomId = await createRoom({ - dmUserId: this.props.config.welcomeUserId, - // Only view the welcome user if we're NOT looking at a room - andView: !this.state.currentRoomId, - }); - // if successful, return because we're already - // viewing the welcomeUserId room - // else, if failed, fall through to view_home_page - if (roomId) { - return; + const welcomeUserRoom = await this._startWelcomeUserChat(); + if (welcomeUserRoom === null) { + // We didn't rediret to the welcome user room, so show + // the homepage. + dis.dispatch({action: 'view_home_page'}); } + } else { + // The user has just logged in after registering, + // so show the homepage. + dis.dispatch({action: 'view_home_page'}); } - // The user has just logged in after registering - dis.dispatch({action: 'view_home_page'}); } else { this._showScreenAfterLogin(); } @@ -1225,10 +1263,7 @@ export default React.createClass({ this._screenAfterLogin = null; } else if (localStorage && localStorage.getItem('mx_last_room_id')) { // Before defaulting to directory, show the last viewed room - dis.dispatch({ - action: 'view_room', - room_id: localStorage.getItem('mx_last_room_id'), - }); + this._viewLastRoom(); } else { if (MatrixClientPeg.get().isGuest()) { dis.dispatch({action: 'view_welcome_page'}); @@ -1242,6 +1277,13 @@ export default React.createClass({ } }, + _viewLastRoom: function() { + dis.dispatch({ + action: 'view_room', + room_id: localStorage.getItem('mx_last_room_id'), + }); + }, + /** * Called when the session is logged out */ @@ -1253,7 +1295,21 @@ export default React.createClass({ collapseLhs: false, collapsedRhs: false, currentRoomId: null, - page_type: PageTypes.RoomDirectory, + }); + this._setPageSubtitle(); + }, + + /** + * Called when the session is softly logged out + */ + _onSoftLogout: function() { + this.notifyNewScreen('soft_logout'); + this.setStateForNewView({ + view: VIEWS.SOFT_LOGOUT, + ready: false, + collapseLhs: false, + collapsedRhs: false, + currentRoomId: null, }); this._setPageSubtitle(); }, @@ -1337,8 +1393,15 @@ export default React.createClass({ call: call, }, true); }); - cli.on('Session.logged_out', function(call) { + cli.on('Session.logged_out', function(errObj) { if (Lifecycle.isLoggingOut()) return; + + if (errObj.httpStatus === 401 && errObj.data && errObj.data['soft_logout']) { + console.warn("Soft logout issued by server - avoiding data deletion"); + Lifecycle.softLogout(); + return; + } + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Signed out', '', ErrorDialog, { title: _t('Signed Out'), @@ -1519,6 +1582,17 @@ export default React.createClass({ action: 'start_password_recovery', params: params, }); + } else if (screen === 'soft_logout') { + if (MatrixClientPeg.get() && MatrixClientPeg.get().getUserId() && !Lifecycle.isSoftLogout()) { + // Logged in - visit a room + this._viewLastRoom(); + } else { + // Ultimately triggers soft_logout if needed + dis.dispatch({ + action: 'start_login', + params: params, + }); + } } else if (screen == 'new') { dis.dispatch({ action: 'view_create_room', @@ -1710,48 +1784,6 @@ export default React.createClass({ // returns a promise which resolves to the new MatrixClient onRegistered: function(credentials) { - if (this.state.register_session_id) { - // The user came in through an email validation link. To avoid overwriting - // their session, check to make sure the session isn't someone else, and - // isn't a guest user since we'll usually have set a guest user session before - // starting the registration process. This isn't perfect since it's possible - // the user had a separate guest session they didn't actually mean to replace. - const sessionOwner = Lifecycle.getStoredSessionOwner(); - const sessionIsGuest = Lifecycle.getStoredSessionIsGuest(); - if (sessionOwner && !sessionIsGuest && sessionOwner !== credentials.userId) { - console.log( - `Found a session for ${sessionOwner} but ${credentials.userId} is trying to verify their ` + - `email address. Restoring the session for ${sessionOwner} with warning.`, - ); - this._loadSession(); - - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - // N.B. first param is passed to piwik and so doesn't want i18n - Modal.createTrackedDialog('Existing session on register', '', - QuestionDialog, { - title: _t('You are logged in to another account'), - description: _t( - "Thank you for verifying your email! The account you're logged into here " + - "(%(sessionUserId)s) appears to be different from the account you've verified an " + - "email for (%(verifiedUserId)s). If you would like to log in to %(verifiedUserId2)s, " + - "please log out first.", { - sessionUserId: sessionOwner, - verifiedUserId: credentials.userId, - - // TODO: Fix translations to support reusing variables. - // https://github.com/vector-im/riot-web/issues/9086 - verifiedUserId2: credentials.userId, - }, - ), - hasCancelButton: false, - }); - - return MatrixClientPeg.get(); - } - } - // XXX: This should be in state or ideally store(s) because we risk not - // rendering the most up-to-date view of state otherwise. - this._is_registered = true; return Lifecycle.setLoggedIn(credentials); }, @@ -1792,19 +1824,7 @@ export default React.createClass({ }, updateStatusIndicator: function(state, prevState) { - let notifCount = 0; - - const rooms = MatrixClientPeg.get().getRooms(); - for (let i = 0; i < rooms.length; ++i) { - if (rooms[i].hasMembershipState(MatrixClientPeg.get().credentials.userId, 'invite')) { - notifCount++; - } else if (rooms[i].getUnreadNotificationCount()) { - // if we were summing unread notifs: - // notifCount += rooms[i].getUnreadNotificationCount(); - // instead, we just count the number of rooms with notifs. - notifCount++; - } - } + const notifCount = countRoomsWithNotif(MatrixClientPeg.get().getRooms()).count; if (PlatformPeg.get()) { PlatformPeg.get().setErrorStatus(state === 'ERROR'); @@ -1827,44 +1847,7 @@ export default React.createClass({ }, onServerConfigChange(config) { - const newState = {}; - if (config.hsUrl) { - newState.register_hs_url = config.hsUrl; - } - if (config.isUrl) { - newState.register_is_url = config.isUrl; - } - this.setState(newState); - }, - - _tryDiscoverDefaultHomeserver: async function(serverName) { - try { - const discovery = await AutoDiscovery.findClientConfig(serverName); - const state = discovery["m.homeserver"].state; - if (state !== AutoDiscovery.SUCCESS) { - console.error("Failed to discover homeserver on startup:", discovery); - this.setState({ - defaultServerDiscoveryError: discovery["m.homeserver"].error, - loadingDefaultHomeserver: false, - }); - } else { - const hsUrl = discovery["m.homeserver"].base_url; - const isUrl = discovery["m.identity_server"].state === AutoDiscovery.SUCCESS - ? discovery["m.identity_server"].base_url - : "https://vector.im"; - this.setState({ - defaultHsUrl: hsUrl, - defaultIsUrl: isUrl, - loadingDefaultHomeserver: false, - }); - } - } catch (e) { - console.error(e); - this.setState({ - defaultServerDiscoveryError: _t("Unknown error discovering homeserver"), - loadingDefaultHomeserver: false, - }); - } + this.setState({serverConfig: config}); }, _makeRegistrationUrl: function(params) { @@ -1883,8 +1866,7 @@ export default React.createClass({ if ( this.state.view === VIEWS.LOADING || - this.state.view === VIEWS.LOGGING_IN || - this.state.loadingDefaultHomeserver + this.state.view === VIEWS.LOGGING_IN ) { const Spinner = sdk.getComponent('elements.Spinner'); return ( @@ -1962,18 +1944,13 @@ export default React.createClass({ sessionId={this.state.register_session_id} idSid={this.state.register_id_sid} email={this.props.startingFragmentQueryParams.email} - defaultServerName={this.getDefaultServerName()} - defaultServerDiscoveryError={this.state.defaultServerDiscoveryError} - defaultHsUrl={this.getDefaultHsUrl()} - defaultIsUrl={this.getDefaultIsUrl()} brand={this.props.config.brand} - customHsUrl={this.getCurrentHsUrl()} - customIsUrl={this.getCurrentIsUrl()} makeRegistrationUrl={this._makeRegistrationUrl} onLoggedIn={this.onRegistered} onLoginClick={this.onLoginClick} onServerConfigChange={this.onServerConfigChange} - /> + {...this.getServerProperties()} + /> ); } @@ -1982,14 +1959,11 @@ export default React.createClass({ const ForgotPassword = sdk.getComponent('structures.auth.ForgotPassword'); return ( + onLoginClick={this.onLoginClick} + onServerConfigChange={this.onServerConfigChange} + {...this.getServerProperties()} + /> ); } @@ -1999,16 +1973,21 @@ export default React.createClass({ + ); + } + + if (this.state.view === VIEWS.SOFT_LOGOUT) { + const SoftLogout = sdk.getComponent('structures.auth.SoftLogout'); + return ( + ); } diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 17e44f2a0f..1fb0d6c725 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -15,6 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +/* global Velocity */ + import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; @@ -29,6 +31,8 @@ import SettingsStore from '../../settings/SettingsStore'; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = ['m.sticker', 'm.room.message']; +const isMembershipChange = (e) => e.getType() === 'm.room.member' || e.getType() === 'm.room.third_party_invite'; + /* (almost) stateless UI component which builds the event tiles in the room timeline. */ module.exports = React.createClass({ @@ -52,6 +56,10 @@ module.exports = React.createClass({ // ID of an event to highlight. If undefined, no event will be highlighted. highlightedEventId: PropTypes.string, + // The room these events are all in together, if any. + // (The notification panel won't have a room here, for example.) + room: PropTypes.object, + // Should we show URL Previews showUrlPreview: PropTypes.bool, @@ -115,10 +123,48 @@ module.exports = React.createClass({ // to manage its animations this._readReceiptMap = {}; + // Track read receipts by event ID. For each _shown_ event ID, we store + // the list of read receipts to display: + // [ + // { + // userId: string, + // member: RoomMember, + // ts: number, + // }, + // ] + // This is recomputed on each render. It's only stored on the component + // for ease of passing the data around since it's computed in one pass + // over all events. + this._readReceiptsByEvent = {}; + + // Track read receipts by user ID. For each user ID we've ever shown a + // a read receipt for, we store an object: + // { + // lastShownEventId: string, + // receipt: { + // userId: string, + // member: RoomMember, + // ts: number, + // }, + // } + // so that we can always keep receipts displayed by reverting back to + // the last shown event for that user ID when needed. This may feel like + // it duplicates the receipt storage in the room, but at this layer, we + // are tracking _shown_ event IDs, which the JS SDK knows nothing about. + // This is recomputed on each render, using the data from the previous + // render as our fallback for any user IDs we can't match a receipt to a + // displayed event in the current render cycle. + this._readReceiptsByUserId = {}; + // Remember the read marker ghost node so we can do the cleanup that // Velocity requires this._readMarkerGhostNode = null; + // Cache hidden events setting on mount since Settings is expensive to + // query, and we check this in a hot code path. + this._showHiddenEventsInTimeline = + SettingsStore.getValue("showHiddenEventsInTimeline"); + this._isMounted = true; }, @@ -234,6 +280,13 @@ module.exports = React.createClass({ } }, + scrollToEventIfNeeded: function(eventId) { + const node = this.eventNodes[eventId]; + if (node) { + node.scrollIntoView({block: "nearest", behavior: "instant"}); + } + }, + /* check the scroll state and send out pagination requests if necessary. */ checkFillState: function() { @@ -252,7 +305,7 @@ module.exports = React.createClass({ return false; // ignored = no show (only happens if the ignore happens after an event was received) } - if (SettingsStore.getValue("showHiddenEventsInTimeline")) { + if (this._showHiddenEventsInTimeline) { return true; } @@ -318,7 +371,10 @@ module.exports = React.createClass({ this.currentGhostEventId = null; } - const isMembershipChange = (e) => e.getType() === 'm.room.member'; + this._readReceiptsByEvent = {}; + if (this.props.showReadReceipts) { + this._readReceiptsByEvent = this._getReadReceiptsByShownEvent(); + } for (i = 0; i < this.props.events.length; i++) { const mxEv = this.props.events[i]; @@ -387,7 +443,7 @@ module.exports = React.createClass({ // In order to prevent DateSeparators from appearing in the expanded form // of MemberEventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the - // timestamp of the current event, and no DateSeperator is inserted. + // timestamp of the current event, and no DateSeparator is inserted. return this._getTilesForEvent(e, e, e === lastShownEvent); }).reduce((a, b) => a.concat(b)); @@ -461,7 +517,8 @@ module.exports = React.createClass({ const DateSeparator = sdk.getComponent('messages.DateSeparator'); const ret = []; - const isEditing = this.props.editEvent && this.props.editEvent.getId() === mxEv.getId(); + const isEditing = this.props.editState && + this.props.editState.getEvent().getId() === mxEv.getId(); // is this a continuation of the previous message? let continuation = false; @@ -518,10 +575,8 @@ module.exports = React.createClass({ // Local echos have a send "status". const scrollToken = mxEv.status ? undefined : eventId; - let readReceipts; - if (this.props.showReadReceipts) { - readReceipts = this._getReadReceiptsForEvent(mxEv); - } + const readReceipts = this._readReceiptsByEvent[eventId]; + ret.push(
  • { - return r2.ts - r1.ts; - }); + // Get an object that maps from event ID to a list of read receipts that + // should be shown next to that event. If a hidden event has read receipts, + // they are folded into the receipts of the last shown event. + _getReadReceiptsByShownEvent: function() { + const receiptsByEvent = {}; + const receiptsByUserId = {}; + + let lastShownEventId; + for (const event of this.props.events) { + if (this._shouldShowEvent(event)) { + lastShownEventId = event.getId(); + } + if (!lastShownEventId) { + continue; + } + + const existingReceipts = receiptsByEvent[lastShownEventId] || []; + const newReceipts = this._getReadReceiptsForEvent(event); + receiptsByEvent[lastShownEventId] = existingReceipts.concat(newReceipts); + + // Record these receipts along with their last shown event ID for + // each associated user ID. + for (const receipt of newReceipts) { + receiptsByUserId[receipt.userId] = { + lastShownEventId, + receipt, + }; + } + } + + // It's possible in some cases (for example, when a read receipt + // advances before we have paginated in the new event that it's marking + // received) that we can temporarily not have a matching event for + // someone which had one in the last. By looking through our previous + // mapping of receipts by user ID, we can cover recover any receipts + // that would have been lost by using the same event ID from last time. + for (const userId in this._readReceiptsByUserId) { + if (receiptsByUserId[userId]) { + continue; + } + const { lastShownEventId, receipt } = this._readReceiptsByUserId[userId]; + const existingReceipts = receiptsByEvent[lastShownEventId] || []; + receiptsByEvent[lastShownEventId] = existingReceipts.concat(receipt); + receiptsByUserId[userId] = { lastShownEventId, receipt }; + } + this._readReceiptsByUserId = receiptsByUserId; + + // After grouping receipts by shown events, do another pass to sort each + // receipt list. + for (const eventId in receiptsByEvent) { + receiptsByEvent[eventId].sort((r1, r2) => { + return r2.ts - r1.ts; + }); + } + + return receiptsByEvent; }, _getReadMarkerTile: function(visible) { @@ -615,6 +725,7 @@ module.exports = React.createClass({ this._readMarkerGhostNode = ghostNode; if (ghostNode) { + // eslint-disable-next-line new-cap Velocity(ghostNode, {opacity: '0', width: '10%'}, {duration: 400, easing: 'easeInSine', delay: 1000}); diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 7411c7e6c1..aec4767e7b 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -1,5 +1,6 @@ /* Copyright 2017 Vector Creations 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. @@ -16,19 +17,15 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import { MatrixClient } from 'matrix-js-sdk'; import sdk from '../../index'; import { _t } from '../../languageHandler'; import dis from '../../dispatcher'; -import withMatrixClient from '../../wrappers/withMatrixClient'; import AccessibleButton from '../views/elements/AccessibleButton'; -export default withMatrixClient(React.createClass({ +export default React.createClass({ displayName: 'MyGroups', - propTypes: { - matrixClient: PropTypes.object.isRequired, - }, - getInitialState: function() { return { groups: null, @@ -36,6 +33,10 @@ export default withMatrixClient(React.createClass({ }; }, + contextTypes: { + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, + }, + componentWillMount: function() { this._fetch(); }, @@ -45,7 +46,7 @@ export default withMatrixClient(React.createClass({ }, _fetch: function() { - this.props.matrixClient.getJoinedGroups().done((result) => { + this.context.matrixClient.getJoinedGroups().done((result) => { this.setState({groups: result.groups, error: null}); }, (err) => { if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN') { @@ -146,4 +147,4 @@ export default withMatrixClient(React.createClass({
  • ; }, -})); +}); diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index a1e0af3606..31e4788a0b 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -3,6 +3,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2017 New Vector 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. @@ -30,9 +31,9 @@ import GroupStore from '../../stores/GroupStore'; export default class RightPanel extends React.Component { static get propTypes() { return { - roomId: React.PropTypes.string, // if showing panels for a given room, this is set - groupId: React.PropTypes.string, // if showing panels for a given group, this is set - user: React.PropTypes.object, + roomId: PropTypes.string, // if showing panels for a given room, this is set + groupId: PropTypes.string, // if showing panels for a given group, this is set + user: PropTypes.object, }; } diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index 5342276e63..8d8ad96ff6 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket 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. @@ -25,6 +26,7 @@ const sdk = require('../../index'); const dis = require('../../dispatcher'); import { linkifyAndSanitizeHtml } from '../../HtmlUtils'; +import PropTypes from 'prop-types'; import Promise from 'bluebird'; import { _t } from '../../languageHandler'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; @@ -41,8 +43,8 @@ module.exports = React.createClass({ displayName: 'RoomDirectory', propTypes: { - config: React.PropTypes.object, - onFinished: React.PropTypes.func.isRequired, + config: PropTypes.object, + onFinished: PropTypes.func.isRequired, }, getDefaultProps: function() { @@ -65,7 +67,7 @@ module.exports = React.createClass({ }, childContextTypes: { - matrixClient: React.PropTypes.object, + matrixClient: PropTypes.object, }, getChildContext: function() { @@ -145,7 +147,7 @@ module.exports = React.createClass({ // too. If it's changed, appending to the list will corrupt it. const my_next_batch = this.nextBatch; const opts = {limit: 20}; - if (my_server != MatrixClientPeg.getHomeServerName()) { + if (my_server != MatrixClientPeg.getHomeserverName()) { opts.server = my_server; } if (this.state.instanceId) { @@ -333,7 +335,7 @@ module.exports = React.createClass({ if (alias.indexOf(':') == -1) { alias = alias + ':' + this.state.roomServer; } - this.showRoomAlias(alias); + this.showRoomAlias(alias, true); } else { // This is a 3rd party protocol. Let's see if we can join it const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); @@ -349,7 +351,7 @@ module.exports = React.createClass({ } MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).done((resp) => { if (resp.length > 0 && resp[0].alias) { - this.showRoomAlias(resp[0].alias); + this.showRoomAlias(resp[0].alias, true); } else { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Room not found', '', ErrorDialog, { @@ -367,13 +369,16 @@ module.exports = React.createClass({ } }, - showRoomAlias: function(alias) { - this.showRoom(null, alias); + showRoomAlias: function(alias, autoJoin=false) { + this.showRoom(null, alias, autoJoin); }, - showRoom: function(room, room_alias) { + showRoom: function(room, room_alias, autoJoin=false) { this.props.onFinished(); - const payload = {action: 'view_room'}; + const payload = { + action: 'view_room', + auto_join: autoJoin, + }; if (room) { // Don't let the user view a room they won't be able to either // peek or join: fail earlier so they don't have to click back diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index 9ca9d3261d..fa74180a2c 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -194,6 +194,7 @@ const RoomSubList = React.createClass({ _getHeaderJsx: function(isCollapsed) { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const AccessibleTooltipButton = sdk.getComponent('elements.AccessibleTooltipButton'); const subListNotifications = !this.props.isInvite ? RoomNotifs.aggregateNotificationCount(this.props.list) : {count: 0, highlight: true}; @@ -234,7 +235,7 @@ const RoomSubList = React.createClass({ let addRoomButton; if (this.props.onAddRoom) { addRoomButton = ( -
    ); + chevron = (
    ); } const tabindex = this.props.isFiltered ? "0" : "-1"; diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 7c0710a18d..5edf19f3ef 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -2,6 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018, 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. @@ -26,8 +27,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import Promise from 'bluebird'; -import filesize from 'filesize'; import classNames from 'classnames'; +import {Room} from "matrix-js-sdk"; import { _t } from '../../languageHandler'; import {RoomPermalinkCreator} from '../../matrix-to'; @@ -63,6 +64,12 @@ if (DEBUG) { debuglog = console.log.bind(console); } +const RoomContext = PropTypes.shape({ + canReact: PropTypes.bool.isRequired, + canReply: PropTypes.bool.isRequired, + room: PropTypes.instanceOf(Room), +}); + module.exports = React.createClass({ displayName: 'RoomView', propTypes: { @@ -87,7 +94,7 @@ module.exports = React.createClass({ // * name (string) The room's name // * avatarUrl (string) The mxc:// avatar URL for the room // * inviterName (string) The display name of the person who - // * invited us tovthe room + // * invited us to the room oobData: PropTypes.object, // is the RightPanel collapsed? @@ -155,6 +162,24 @@ module.exports = React.createClass({ // We load this later by asking the js-sdk to suggest a version for us. // This object is the result of Room#getRecommendedVersion() upgradeRecommendation: null, + + canReact: false, + canReply: false, + }; + }, + + childContextTypes: { + room: RoomContext, + }, + + getChildContext: function() { + const {canReact, canReply, room} = this.state; + return { + room: { + canReact, + canReply, + room, + }, }; }, @@ -164,6 +189,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.name", this.onRoomName); MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData); + MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership); MatrixClientPeg.get().on("accountData", this.onAccountData); @@ -671,6 +697,7 @@ module.exports = React.createClass({ this._loadMembersIfJoined(room); this._calculateRecommendedVersion(room); this._updateE2EStatus(room); + this._updatePermissions(room); }, _calculateRecommendedVersion: async function(room) { @@ -794,6 +821,15 @@ module.exports = React.createClass({ } }, + onRoomStateEvents: function(ev, state) { + // ignore if we don't have a room yet + if (!this.state.room || this.state.room.roomId !== state.roomId) { + return; + } + + this._updatePermissions(this.state.room); + }, + onRoomStateMember: function(ev, state, member) { // ignore if we don't have a room yet if (!this.state.room) { @@ -812,6 +848,17 @@ module.exports = React.createClass({ if (room.roomId === this.state.roomId) { this.forceUpdate(); this._loadMembersIfJoined(room); + this._updatePermissions(room); + } + }, + + _updatePermissions: function(room) { + if (room) { + const me = MatrixClientPeg.get().getUserId(); + const canReact = room.getMyMembership() === "join" && room.currentState.maySendEvent("m.reaction", me); + const canReply = room.maySendMessage(); + + this.setState({canReact, canReply}); } }, @@ -1503,7 +1550,6 @@ module.exports = React.createClass({ render: function() { const RoomHeader = sdk.getComponent('rooms.RoomHeader'); - const MessageComposer = sdk.getComponent('rooms.MessageComposer'); const ForwardMessage = sdk.getComponent("rooms.ForwardMessage"); const AuxPanel = sdk.getComponent("rooms.AuxPanel"); const SearchBar = sdk.getComponent("rooms.SearchBar"); @@ -1522,9 +1568,11 @@ module.exports = React.createClass({
    ); @@ -1551,6 +1599,8 @@ module.exports = React.createClass({ joining={this.state.joining} inviterName={inviterName} invitedEmail={invitedEmail} + oobData={this.props.oobData} + signUrl={this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : null} room={this.state.room} />
    @@ -1681,6 +1731,7 @@ module.exports = React.createClass({ joining={this.state.joining} inviterName={inviterName} invitedEmail={invitedEmail} + oobData={this.props.oobData} canPreview={this.state.canPeek} room={this.state.room} /> @@ -1726,15 +1777,29 @@ module.exports = React.createClass({ myMembership === 'join' && !this.state.searchResults ); if (canSpeak) { - messageComposer = - ; + if (SettingsStore.isFeatureEnabled("feature_cider_composer")) { + const MessageComposer = sdk.getComponent('rooms.MessageComposer'); + messageComposer = + ; + } else { + const SlateMessageComposer = sdk.getComponent('rooms.SlateMessageComposer'); + messageComposer = + ; + } } // TODO: Why aren't we storing the term/scope/count in this format @@ -1910,3 +1975,5 @@ module.exports = React.createClass({ ); }, }); + +module.exports.RoomContext = RoomContext; diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 7e1f0ff469..40caa627af 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -214,6 +214,9 @@ module.exports = React.createClass({ // after an update to the contents of the panel, check that the scroll is // where it ought to be, and set off pagination requests if necessary. checkScroll: function() { + if (this.unmounted) { + return; + } this._restoreSavedScrollState(); this.checkFillState(); }, diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 0b7b315915..5c18267637 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -2,6 +2,7 @@ Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations 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. @@ -35,6 +36,8 @@ const Modal = require("../../Modal"); const UserActivity = require("../../UserActivity"); import { KeyCode } from '../../Keyboard'; import Timer from '../../utils/Timer'; +import shouldHideEvent from '../../shouldHideEvent'; +import EditorStateTransfer from '../../utils/EditorStateTransfer'; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -140,6 +143,7 @@ const TimelinePanel = React.createClass({ return { events: [], + liveEvents: [], timelineLoading: true, // track whether our room timeline is loading // canBackPaginate == false may mean: @@ -207,6 +211,8 @@ const TimelinePanel = React.createClass({ MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset); MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction); + // same event handler as Room.redaction as for both we just do forceUpdate + MatrixClientPeg.get().on("Room.redactionCancelled", this.onRoomRedaction); MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated); MatrixClientPeg.get().on("Room.accountData", this.onAccountData); @@ -286,6 +292,7 @@ const TimelinePanel = React.createClass({ client.removeListener("Room.timeline", this.onRoomTimeline); client.removeListener("Room.timelineReset", this.onRoomTimelineReset); client.removeListener("Room.redaction", this.onRoomRedaction); + client.removeListener("Room.redactionCancelled", this.onRoomRedaction); client.removeListener("Room.receipt", this.onRoomReceipt); client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated); client.removeListener("Room.accountData", this.onAccountData); @@ -318,9 +325,11 @@ const TimelinePanel = React.createClass({ // We can now paginate in the unpaginated direction const canPaginateKey = (backwards) ? 'canBackPaginate' : 'canForwardPaginate'; + const { events, liveEvents } = this._getEvents(); this.setState({ [canPaginateKey]: true, - events: this._getEvents(), + events, + liveEvents, }); } }, @@ -352,10 +361,12 @@ const TimelinePanel = React.createClass({ debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r); + const { events, liveEvents } = this._getEvents(); const newState = { [paginatingKey]: false, [canPaginateKey]: r, - events: this._getEvents(), + events, + liveEvents, }; // moving the window in this direction may mean that we can now @@ -408,7 +419,14 @@ const TimelinePanel = React.createClass({ this.forceUpdate(); } if (payload.action === "edit_event") { - this.setState({editEvent: payload.event}); + const editState = payload.event ? new EditorStateTransfer(payload.event) : null; + this.setState({editState}, () => { + if (payload.event && this.refs.messagePanel) { + this.refs.messagePanel.scrollToEventIfNeeded( + payload.event.getId(), + ); + } + }); } }, @@ -442,15 +460,13 @@ const TimelinePanel = React.createClass({ this._timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).done(() => { if (this.unmounted) { return; } - const events = this._timelineWindow.getEvents(); - const lastEv = events[events.length-1]; + const { events, liveEvents } = this._getEvents(); + const lastLiveEvent = liveEvents[liveEvents.length - 1]; - // if we're at the end of the live timeline, append the pending events - if (this.props.timelineSet.room && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { - events.push(...this.props.timelineSet.room.getPendingEvents()); - } - - const updatedState = {events: events}; + const updatedState = { + events, + liveEvents, + }; let callRMUpdated; if (this.props.manageReadMarkers) { @@ -467,13 +483,13 @@ const TimelinePanel = React.createClass({ callRMUpdated = false; if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) { updatedState.readMarkerVisible = true; - } else if (lastEv && this.getReadMarkerPosition() === 0) { + } else if (lastLiveEvent && this.getReadMarkerPosition() === 0) { // we know we're stuckAtBottom, so we can advance the RM // immediately, to save a later render cycle - this._setReadMarker(lastEv.getId(), lastEv.getTs(), true); + this._setReadMarker(lastLiveEvent.getId(), lastLiveEvent.getTs(), true); updatedState.readMarkerVisible = false; - updatedState.readMarkerEventId = lastEv.getId(); + updatedState.readMarkerEventId = lastLiveEvent.getId(); callRMUpdated = true; } } @@ -607,6 +623,8 @@ const TimelinePanel = React.createClass({ }, sendReadReceipt: function() { + if (SettingsStore.getValue("lowBandwidth")) return; + if (!this.refs.messagePanel) return; if (!this.props.manageReadReceipts) return; // This happens on user_activity_end which is delayed, and it's @@ -680,9 +698,12 @@ const TimelinePanel = React.createClass({ if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) { return MatrixClientPeg.get().sendReadReceipt( lastReadEvent, - ).catch(() => { + ).catch((e) => { + console.error(e); this.lastRRSentEventId = undefined; }); + } else { + console.error(e); } // it failed, so allow retries next time the user is active this.lastRRSentEventId = undefined; @@ -717,14 +738,8 @@ const TimelinePanel = React.createClass({ // move the RM to *after* the message at the bottom of the screen. This // avoids a problem whereby we never advance the RM if there is a huge // message which doesn't fit on the screen. - // - // But ignore local echoes for this - they have a temporary event ID - // and we'll get confused when their ID changes and we can't figure out - // where the RM is pointing to. The read marker will be invisible for - // now anyway, so this doesn't really matter. const lastDisplayedIndex = this._getLastDisplayedEventIndex({ allowPartial: true, - ignoreEchoes: true, }); if (lastDisplayedIndex === null) { @@ -748,9 +763,9 @@ const TimelinePanel = React.createClass({ _advanceReadMarkerPastMyEvents: function() { if (!this.props.manageReadMarkers) return; - // we call _timelineWindow.getEvents() rather than using - // this.state.events, because react batches the update to the latter, so it - // may not have been updated yet. + // we call `_timelineWindow.getEvents()` rather than using + // `this.state.liveEvents`, because React batches the update to the + // latter, so it may not have been updated yet. const events = this._timelineWindow.getEvents(); // first find where the current RM is @@ -1053,6 +1068,7 @@ const TimelinePanel = React.createClass({ } else { this.setState({ events: [], + liveEvents: [], canBackPaginate: false, canForwardPaginate: false, timelineLoading: true, @@ -1072,21 +1088,26 @@ const TimelinePanel = React.createClass({ // the results if so. if (this.unmounted) return; - this.setState({ - events: this._getEvents(), - }); + this.setState(this._getEvents()); }, // get the list of events from the timeline window and the pending event list _getEvents: function() { const events = this._timelineWindow.getEvents(); + // Hold onto the live events separately. The read receipt and read marker + // should use this list, so that they don't advance into pending events. + const liveEvents = [...events]; + // if we're at the end of the live timeline, append the pending events if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { events.push(...this.props.timelineSet.getPendingEvents()); } - return events; + return { + events, + liveEvents, + }; }, _indexForEventId: function(evId) { @@ -1101,36 +1122,76 @@ const TimelinePanel = React.createClass({ _getLastDisplayedEventIndex: function(opts) { opts = opts || {}; const ignoreOwn = opts.ignoreOwn || false; - const ignoreEchoes = opts.ignoreEchoes || false; const allowPartial = opts.allowPartial || false; const messagePanel = this.refs.messagePanel; if (messagePanel === undefined) return null; + const EventTile = sdk.getComponent('rooms.EventTile'); + const wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect(); const myUserId = MatrixClientPeg.get().credentials.userId; - for (let i = this.state.events.length-1; i >= 0; --i) { - const ev = this.state.events[i]; - - if (ignoreOwn && ev.sender && ev.sender.userId == myUserId) { - continue; + const isNodeInView = (node) => { + if (node) { + const boundingRect = node.getBoundingClientRect(); + if ((allowPartial && boundingRect.top < wrapperRect.bottom) || + (!allowPartial && boundingRect.bottom < wrapperRect.bottom)) { + return true; + } } + return false; + }; - // local echoes have a fake event ID - if (ignoreEchoes && ev.status) { - continue; - } + // We keep track of how many of the adjacent events didn't have a tile + // but should have the read receipt moved past them, so + // we can include those once we find the last displayed (visible) event. + // The counter is not started for events we don't want + // to send a read receipt for (our own events, local echos). + let adjacentInvisibleEventCount = 0; + // Use `liveEvents` here because we don't want the read marker or read + // receipt to advance into pending events. + for (let i = this.state.liveEvents.length - 1; i >= 0; --i) { + const ev = this.state.liveEvents[i]; const node = messagePanel.getNodeForEventId(ev.getId()); - if (!node) continue; + const isInView = isNodeInView(node); - const boundingRect = node.getBoundingClientRect(); - if ((allowPartial && boundingRect.top < wrapperRect.bottom) || - (!allowPartial && boundingRect.bottom < wrapperRect.bottom)) { + // when we've reached the first visible event, and the previous + // events were all invisible (with the first one not being ignored), + // return the index of the first invisible event. + if (isInView && adjacentInvisibleEventCount !== 0) { + return i + adjacentInvisibleEventCount; + } + if (node && !isInView) { + // has node but not in view, so reset adjacent invisible events + adjacentInvisibleEventCount = 0; + } + + const shouldIgnore = !!ev.status || // local echo + (ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message + const isWithoutTile = !EventTile.haveTileForEvent(ev) || shouldHideEvent(ev); + + if (isWithoutTile || !node) { + // don't start counting if the event should be ignored, + // but continue counting if we were already so the offset + // to the previous invisble event that didn't need to be ignored + // doesn't get messed up + if (!shouldIgnore || (shouldIgnore && adjacentInvisibleEventCount !== 0)) { + ++adjacentInvisibleEventCount; + } + continue; + } + + if (shouldIgnore) { + continue; + } + + if (isInView) { return i; } } + return null; }, @@ -1266,7 +1327,7 @@ const TimelinePanel = React.createClass({ tileShape={this.props.tileShape} resizeNotifier={this.props.resizeNotifier} getRelationsForEvent={this.getRelationsForEvent} - editEvent={this.state.editEvent} + editState={this.state.editState} showReactions={this.props.showReactions} /> ); diff --git a/src/components/structures/TopLeftMenuButton.js b/src/components/structures/TopLeftMenuButton.js index f745a7f7bc..cf3dda077c 100644 --- a/src/components/structures/TopLeftMenuButton.js +++ b/src/components/structures/TopLeftMenuButton.js @@ -98,10 +98,12 @@ export default class TopLeftMenuButton extends React.Component { render() { const name = this._getDisplayName(); let nameElement; + let chevronElement; if (!this.props.collapsed) { nameElement =
    { name }
    ; + chevronElement = ; } return ( @@ -121,7 +123,7 @@ export default class TopLeftMenuButton extends React.Component { resizeMethod="crop" /> { nameElement } - + { chevronElement } ); } diff --git a/src/components/structures/UserView.js b/src/components/structures/UserView.js index 2fe9c0937c..26d0ff5044 100644 --- a/src/components/structures/UserView.js +++ b/src/components/structures/UserView.js @@ -1,5 +1,6 @@ /* Copyright 2019 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. @@ -15,6 +16,7 @@ limitations under the License. */ import React from "react"; +import PropTypes from "prop-types"; import Matrix from "matrix-js-sdk"; import MatrixClientPeg from "../../MatrixClientPeg"; import sdk from "../../index"; @@ -24,7 +26,7 @@ import { _t } from '../../languageHandler'; export default class UserView extends React.Component { static get propTypes() { return { - userId: React.PropTypes.string, + userId: PropTypes.string, }; } diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.js index 46071f0a9c..11c0ff8295 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018, 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. @@ -21,8 +22,9 @@ import { _t } from '../../../languageHandler'; import sdk from '../../../index'; import Modal from "../../../Modal"; import SdkConfig from "../../../SdkConfig"; - import PasswordReset from "../../../PasswordReset"; +import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import classNames from 'classnames'; // Phases // Show controls to configure server details @@ -40,41 +42,68 @@ module.exports = React.createClass({ displayName: 'ForgotPassword', propTypes: { - // The default server name to use when the user hasn't specified - // one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this - // via `.well-known` discovery. The server name is used instead of the - // HS URL when talking about "your account". - defaultServerName: PropTypes.string, - // An error passed along from higher up explaining that something - // went wrong when finding the defaultHsUrl. - defaultServerDiscoveryError: PropTypes.string, - - defaultHsUrl: PropTypes.string, - defaultIsUrl: PropTypes.string, - customHsUrl: PropTypes.string, - customIsUrl: PropTypes.string, - + serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, + onServerConfigChange: PropTypes.func.isRequired, onLoginClick: PropTypes.func, onComplete: PropTypes.func.isRequired, }, getInitialState: function() { return { - enteredHsUrl: this.props.customHsUrl || this.props.defaultHsUrl, - enteredIsUrl: this.props.customIsUrl || this.props.defaultIsUrl, phase: PHASE_FORGOT, email: "", password: "", password2: "", errorText: null, + + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: true, + serverErrorIsFatal: false, + serverDeadError: "", + serverRequiresIdServer: null, }; }, - submitPasswordReset: function(hsUrl, identityUrl, email, password) { + componentWillMount: function() { + this.reset = null; + this._checkServerLiveliness(this.props.serverConfig); + }, + + componentWillReceiveProps: function(newProps) { + if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && + newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; + + // Do a liveliness check on the new URLs + this._checkServerLiveliness(newProps.serverConfig); + }, + + _checkServerLiveliness: async function(serverConfig) { + try { + await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( + serverConfig.hsUrl, + serverConfig.isUrl, + ); + + const pwReset = new PasswordReset(serverConfig.hsUrl, serverConfig.isUrl); + const serverRequiresIdServer = await pwReset.doesServerRequireIdServerParam(); + + this.setState({ + serverIsAlive: true, + serverRequiresIdServer, + }); + } catch (e) { + this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password")); + } + }, + + submitPasswordReset: function(email, password) { this.setState({ phase: PHASE_SENDING_EMAIL, }); - this.reset = new PasswordReset(hsUrl, identityUrl); + this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl); this.reset.resetPassword(email, password).done(() => { this.setState({ phase: PHASE_EMAIL_SENT, @@ -100,15 +129,11 @@ module.exports = React.createClass({ }); }, - onSubmitForm: function(ev) { + onSubmitForm: async function(ev) { ev.preventDefault(); - // Don't allow the user to register if there's a discovery error - // Without this, the user could end up registering on the wrong homeserver. - if (this.props.defaultServerDiscoveryError) { - this.setState({errorText: this.props.defaultServerDiscoveryError}); - return; - } + // refresh the server errors, just in case the server came back online + await this._checkServerLiveliness(this.props.serverConfig); if (!this.state.email) { this.showErrorDialog(_t('The email address linked to your account must be entered.')); @@ -132,10 +157,7 @@ module.exports = React.createClass({ button: _t('Continue'), onFinished: (confirmed) => { if (confirmed) { - this.submitPasswordReset( - this.state.enteredHsUrl, this.state.enteredIsUrl, - this.state.email, this.state.password, - ); + this.submitPasswordReset(this.state.email, this.state.password); } }, }); @@ -148,19 +170,7 @@ module.exports = React.createClass({ }); }, - onServerConfigChange: function(config) { - const newState = {}; - if (config.hsUrl !== undefined) { - newState.enteredHsUrl = config.hsUrl; - } - if (config.isUrl !== undefined) { - newState.enteredIsUrl = config.isUrl; - } - this.setState(newState); - }, - - onServerDetailsNextPhaseClick(ev) { - ev.stopPropagation(); + async onServerDetailsNextPhaseClick() { this.setState({ phase: PHASE_FORGOT, }); @@ -190,56 +200,61 @@ module.exports = React.createClass({ renderServerDetails() { const ServerConfig = sdk.getComponent("auth.ServerConfig"); - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); if (SdkConfig.get()['disable_custom_urls']) { return null; } - return
    - - - {_t("Next")} - -
    ; + return ; }, renderForgot() { const Field = sdk.getComponent('elements.Field'); let errorText = null; - const err = this.state.errorText || this.props.defaultServerDiscoveryError; + const err = this.state.errorText; if (err) { errorText =
    { err }
    ; } - let yourMatrixAccountText = _t('Your Matrix account'); - if (this.state.enteredHsUrl === this.props.defaultHsUrl && this.props.defaultServerName) { - yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', { - serverName: this.props.defaultServerName, + let serverDeadSection; + if (!this.state.serverIsAlive) { + const classes = classNames({ + "mx_Login_error": true, + "mx_Login_serverError": true, + "mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal, + }); + serverDeadSection = ( +
    + {this.state.serverDeadError} +
    + ); + } + + let yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', { + serverName: this.props.serverConfig.hsName, + }); + if (this.props.serverConfig.hsNameIsDifferent) { + const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); + + yourMatrixAccountText = _t('Your Matrix account on ', {}, { + 'underlinedServerName': () => { + return ; + }, }); - } else { - try { - const parsedHsUrl = new URL(this.state.enteredHsUrl); - yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', { - serverName: parsedHsUrl.hostname, - }); - } catch (e) { - errorText =
    {_t( - "The homeserver URL %(hsUrl)s doesn't seem to be valid URL. Please " + - "enter a valid URL including the protocol prefix.", - { - hsUrl: this.state.enteredHsUrl, - })}
    ; - } } // If custom URLs are allowed, wire up the server details edit link. @@ -252,12 +267,29 @@ module.exports = React.createClass({ ; } + if (!this.props.serverConfig.isUrl && this.state.serverRequiresIdServer) { + return
    +

    + {yourMatrixAccountText} + {editLink} +

    + {_t( + "No identity server is configured: " + + "add one in server settings to reset your password.", + )} + + {_t('Sign in instead')} + +
    ; + } + return
    + {errorText} + {serverDeadSection}

    {yourMatrixAccountText} {editLink}

    - {errorText}
    - + {_t('Sign in instead')} diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.js index 2940346a4f..31cb92d982 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.js @@ -20,12 +20,13 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import { _t, _td } from '../../../languageHandler'; +import {_t, _td} from '../../../languageHandler'; import sdk from '../../../index'; import Login from '../../../Login'; import SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; -import { AutoDiscovery } from "matrix-js-sdk"; +import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import classNames from "classnames"; // For validating phone numbers without country codes const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; @@ -59,19 +60,9 @@ module.exports = React.createClass({ propTypes: { onLoggedIn: PropTypes.func.isRequired, - // The default server name to use when the user hasn't specified - // one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this - // via `.well-known` discovery. The server name is used instead of the - // HS URL when talking about where to "sign in to". - defaultServerName: PropTypes.string, - // An error passed along from higher up explaining that something - // went wrong when finding the defaultHsUrl. - defaultServerDiscoveryError: PropTypes.string, + // If true, the component will consider itself busy. + busy: PropTypes.bool, - customHsUrl: PropTypes.string, - customIsUrl: PropTypes.string, - defaultHsUrl: PropTypes.string, - defaultIsUrl: PropTypes.string, // Secondary HS which we try to log into if the user is using // the default HS but login fails. Useful for migrating to a // different homeserver without confusing users. @@ -79,12 +70,13 @@ module.exports = React.createClass({ defaultDeviceDisplayName: PropTypes.string, - // login shouldn't know or care how registration is done. + // login shouldn't know or care how registration, password recovery, + // etc is done. onRegisterClick: PropTypes.func.isRequired, - - // login shouldn't care how password recovery is done. onForgotPasswordClick: PropTypes.func, onServerConfigChange: PropTypes.func.isRequired, + + serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, }, getInitialState: function() { @@ -92,9 +84,7 @@ module.exports = React.createClass({ busy: false, errorText: null, loginIncorrect: false, - - enteredHsUrl: this.props.customHsUrl || this.props.defaultHsUrl, - enteredIsUrl: this.props.customIsUrl || this.props.defaultIsUrl, + canTryLogin: true, // can we attempt to log in or are there validation errors? // used for preserving form values when changing homeserver username: "", @@ -106,9 +96,13 @@ module.exports = React.createClass({ // The current login flow, such as password, SSO, etc. currentFlow: "m.login.password", - // .well-known discovery - discoveryError: "", - findingHomeserver: false, + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: true, + serverErrorIsFatal: false, + serverDeadError: "", }; }, @@ -132,6 +126,14 @@ module.exports = React.createClass({ this._unmounted = true; }, + componentWillReceiveProps(newProps) { + if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && + newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; + + // Ensure that we end up actually logging in to the right place + this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl); + }, + onPasswordLoginError: function(errorText) { this.setState({ errorText, @@ -139,10 +141,35 @@ module.exports = React.createClass({ }); }, - onPasswordLogin: function(username, phoneCountry, phoneNumber, password) { - // Prevent people from submitting their password when homeserver - // discovery went wrong - if (this.state.discoveryError || this.props.defaultServerDiscoveryError) return; + isBusy: function() { + return this.state.busy || this.props.busy; + }, + + onPasswordLogin: async function(username, phoneCountry, phoneNumber, password) { + if (!this.state.serverIsAlive) { + this.setState({busy: true}); + // Do a quick liveliness check on the URLs + let aliveAgain = true; + try { + await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( + this.props.serverConfig.hsUrl, + this.props.serverConfig.isUrl, + ); + this.setState({serverIsAlive: true, errorText: ""}); + } catch (e) { + const componentState = AutoDiscoveryUtils.authComponentStateForError(e); + this.setState({ + busy: false, + ...componentState, + }); + aliveAgain = !componentState.serverErrorIsFatal; + } + + // Prevent people from submitting their password when something isn't right. + if (!aliveAgain) { + return; + } + } this.setState({ busy: true, @@ -153,6 +180,7 @@ module.exports = React.createClass({ this._loginLogic.loginViaPassword( username, phoneCountry, phoneNumber, password, ).then((data) => { + this.setState({serverIsAlive: true}); // it must be, we logged in. this.props.onLoggedIn(data); }, (error) => { if (this._unmounted) { @@ -164,7 +192,7 @@ module.exports = React.createClass({ const usingEmail = username.indexOf("@") > 0; if (error.httpStatus === 400 && usingEmail) { errorText = _t('This homeserver does not support login using email address.'); - } else if (error.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') { + } else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const errorTop = messageForResourceLimitError( error.data.limit_type, error.data.admin_contact, { @@ -189,16 +217,17 @@ module.exports = React.createClass({
    ); } else if (error.httpStatus === 401 || error.httpStatus === 403) { - if (SdkConfig.get()['disable_custom_urls']) { + if (error.errcode === 'M_USER_DEACTIVATED') { + errorText = _t('This account has been deactivated.'); + } else if (SdkConfig.get()['disable_custom_urls']) { errorText = (
    { _t('Incorrect username and/or password.') }
    - { _t('Please note you are logging into the %(hs)s server, not matrix.org.', - { - hs: this.props.defaultHsUrl.replace(/^https?:\/\//, ''), - }) - } + {_t( + 'Please note you are logging into the %(hs)s server, not matrix.org.', + {hs: this.props.serverConfig.hsName}, + )}
    ); @@ -232,21 +261,49 @@ module.exports = React.createClass({ this.setState({ username: username }); }, - onUsernameBlur: function(username) { + onUsernameBlur: async function(username) { + const doWellknownLookup = username[0] === "@"; this.setState({ username: username, - discoveryError: null, + busy: doWellknownLookup, + errorText: null, + canTryLogin: true, }); - if (username[0] === "@") { + if (doWellknownLookup) { const serverName = username.split(':').slice(1).join(':'); try { - // we have to append 'https://' to make the URL constructor happy - // otherwise we get things like 'protocol: matrix.org, pathname: 8448' - const url = new URL("https://" + serverName); - this._tryWellKnownDiscovery(url.hostname); + const result = await AutoDiscoveryUtils.validateServerName(serverName); + this.props.onServerConfigChange(result); + // We'd like to rely on new props coming in via `onServerConfigChange` + // so that we know the servers have definitely updated before clearing + // the busy state. In the case of a full MXID that resolves to the same + // HS as Riot's default HS though, there may not be any server change. + // To avoid this trap, we clear busy here. For cases where the server + // actually has changed, `_initLoginLogic` will be called and manages + // busy state for its own liveness check. + this.setState({ + busy: false, + }); } catch (e) { console.error("Problem parsing URL or unhandled error doing .well-known discovery:", e); - this.setState({discoveryError: _t("Failed to perform homeserver discovery")}); + + let message = _t("Failed to perform homeserver discovery"); + if (e.translatedMessage) { + message = e.translatedMessage; + } + + let errorText = message; + let discoveryState = {}; + if (AutoDiscoveryUtils.isLivelinessError(e)) { + errorText = this.state.errorText; + discoveryState = AutoDiscoveryUtils.authComponentStateForError(e); + } + + this.setState({ + busy: false, + errorText, + ...discoveryState, + }); } } }, @@ -262,44 +319,27 @@ module.exports = React.createClass({ }, onPhoneNumberBlur: function(phoneNumber) { - this.setState({ - errorText: null, - }); - // Validate the phone number entered if (!PHONE_NUMBER_REGEX.test(phoneNumber)) { this.setState({ errorText: _t('The phone number entered looks invalid'), + canTryLogin: false, + }); + } else { + this.setState({ + errorText: null, + canTryLogin: true, }); } }, - onServerConfigChange: function(config) { - const self = this; - const newState = { - errorText: null, // reset err messages - }; - if (config.hsUrl !== undefined) { - newState.enteredHsUrl = config.hsUrl; - } - if (config.isUrl !== undefined) { - newState.enteredIsUrl = config.isUrl; - } - - this.props.onServerConfigChange(config); - this.setState(newState, function() { - self._initLoginLogic(config.hsUrl || null, config.isUrl); - }); - }, - onRegisterClick: function(ev) { ev.preventDefault(); ev.stopPropagation(); this.props.onRegisterClick(); }, - onServerDetailsNextPhaseClick(ev) { - ev.stopPropagation(); + async onServerDetailsNextPhaseClick() { this.setState({ phase: PHASE_LOGIN, }); @@ -313,64 +353,18 @@ module.exports = React.createClass({ }); }, - _tryWellKnownDiscovery: async function(serverName) { - if (!serverName.trim()) { - // Nothing to discover - this.setState({ - discoveryError: "", - findingHomeserver: false, - }); - return; + _initLoginLogic: async function(hsUrl, isUrl) { + hsUrl = hsUrl || this.props.serverConfig.hsUrl; + isUrl = isUrl || this.props.serverConfig.isUrl; + + let isDefaultServer = false; + if (this.props.serverConfig.isDefault + && hsUrl === this.props.serverConfig.hsUrl + && isUrl === this.props.serverConfig.isUrl) { + isDefaultServer = true; } - this.setState({findingHomeserver: true}); - try { - const discovery = await AutoDiscovery.findClientConfig(serverName); - - const state = discovery["m.homeserver"].state; - if (state !== AutoDiscovery.SUCCESS && state !== AutoDiscovery.PROMPT) { - this.setState({ - discoveryError: discovery["m.homeserver"].error, - findingHomeserver: false, - }); - } else if (state === AutoDiscovery.PROMPT) { - this.setState({ - discoveryError: "", - findingHomeserver: false, - }); - } else if (state === AutoDiscovery.SUCCESS) { - this.setState({ - discoveryError: "", - findingHomeserver: false, - }); - this.onServerConfigChange({ - hsUrl: discovery["m.homeserver"].base_url, - isUrl: discovery["m.identity_server"].state === AutoDiscovery.SUCCESS - ? discovery["m.identity_server"].base_url - : "", - }); - } else { - console.warn("Unknown state for m.homeserver in discovery response: ", discovery); - this.setState({ - discoveryError: _t("Unknown failure discovering homeserver"), - findingHomeserver: false, - }); - } - } catch (e) { - console.error(e); - this.setState({ - findingHomeserver: false, - discoveryError: _t("Unknown error discovering homeserver"), - }); - } - }, - - _initLoginLogic: function(hsUrl, isUrl) { - const self = this; - hsUrl = hsUrl || this.state.enteredHsUrl; - isUrl = isUrl || this.state.enteredIsUrl; - - const fallbackHsUrl = hsUrl === this.props.defaultHsUrl ? this.props.fallbackHsUrl : null; + const fallbackHsUrl = isDefaultServer ? this.props.fallbackHsUrl : null; const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, { defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, @@ -378,12 +372,24 @@ module.exports = React.createClass({ this._loginLogic = loginLogic; this.setState({ - enteredHsUrl: hsUrl, - enteredIsUrl: isUrl, busy: true, loginIncorrect: false, }); + // Do a quick liveliness check on the URLs + try { + await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); + this.setState({serverIsAlive: true, errorText: ""}); + } catch (e) { + this.setState({ + busy: false, + ...AutoDiscoveryUtils.authComponentStateForError(e), + }); + if (this.state.serverErrorIsFatal) { + return; // Server is dead - do not continue. + } + } + loginLogic.getFlows().then((flows) => { // look for a flow where we understand all of the steps. for (let i = 0; i < flows.length; i++ ) { @@ -408,13 +414,14 @@ module.exports = React.createClass({ "supported by this client.", ), }); - }, function(err) { - self.setState({ - errorText: self._errorTextFromError(err), + }, (err) => { + this.setState({ + errorText: this._errorTextFromError(err), loginIncorrect: false, + canTryLogin: false, }); - }).finally(function() { - self.setState({ + }).finally(() => { + this.setState({ busy: false, }); }).done(); @@ -445,8 +452,8 @@ module.exports = React.createClass({ if (err.cors === 'rejected') { if (window.location.protocol === 'https:' && - (this.state.enteredHsUrl.startsWith("http:") || - !this.state.enteredHsUrl.startsWith("http")) + (this.props.serverConfig.hsUrl.startsWith("http:") || + !this.props.serverConfig.hsUrl.startsWith("http")) ) { errorText = { _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + @@ -469,9 +476,9 @@ module.exports = React.createClass({ "is not blocking requests.", {}, { 'a': (sub) => { - return
    { sub }; + return + { sub } + ; }, }, ) } @@ -484,7 +491,6 @@ module.exports = React.createClass({ renderServerComponent() { const ServerConfig = sdk.getComponent("auth.ServerConfig"); - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); if (SdkConfig.get()['disable_custom_urls']) { return null; @@ -494,28 +500,19 @@ module.exports = React.createClass({ return null; } - const serverDetails = ; - - let nextButton = null; + const serverDetailsProps = {}; if (PHASES_ENABLED) { - nextButton = - {_t("Next")} - ; + serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; + serverDetailsProps.submitText = _t("Next"); + serverDetailsProps.submitClass = "mx_Login_submit"; } - return
    - {serverDetails} - {nextButton} -
    ; + return ; }, renderLoginComponentForStep() { @@ -547,13 +544,6 @@ module.exports = React.createClass({ onEditServerDetailsClick = this.onEditServerDetailsClick; } - // If the current HS URL is the default HS URL, then we can label it - // with the default HS name (if it exists). - let hsName; - if (this.state.enteredHsUrl === this.props.defaultHsUrl) { - hsName = this.props.defaultServerName; - } - return ( + serverConfig={this.props.serverConfig} + disableSubmit={this.isBusy()} + /> ); }, @@ -595,9 +584,9 @@ module.exports = React.createClass({ const AuthPage = sdk.getComponent("auth.AuthPage"); const AuthHeader = sdk.getComponent("auth.AuthHeader"); const AuthBody = sdk.getComponent("auth.AuthBody"); - const loader = this.state.busy ?
    : null; + const loader = this.isBusy() ?
    : null; - const errorText = this.props.defaultServerDiscoveryError || this.state.discoveryError || this.state.errorText; + const errorText = this.state.errorText; let errorTextSection; if (errorText) { @@ -608,6 +597,20 @@ module.exports = React.createClass({ ); } + let serverDeadSection; + if (!this.state.serverIsAlive) { + const classes = classNames({ + "mx_Login_error": true, + "mx_Login_serverError": true, + "mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal, + }); + serverDeadSection = ( +
    + {this.state.serverDeadError} +
    + ); + } + return ( @@ -617,6 +620,7 @@ module.exports = React.createClass({ {loader} { errorTextSection } + { serverDeadSection } { this.renderServerComponent() } { this.renderLoginComponentForStep() } diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.js index 04ae90c394..2fd028ea1d 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.js @@ -18,16 +18,18 @@ limitations under the License. */ import Matrix from 'matrix-js-sdk'; - import Promise from 'bluebird'; import React from 'react'; import PropTypes from 'prop-types'; - import sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; import * as ServerType from '../../views/auth/ServerTypeSelector'; +import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import classNames from "classnames"; +import * as Lifecycle from '../../../Lifecycle'; +import MatrixClientPeg from "../../../MatrixClientPeg"; // Phases // Show controls to configure server details @@ -47,18 +49,7 @@ module.exports = React.createClass({ sessionId: PropTypes.string, makeRegistrationUrl: PropTypes.func.isRequired, idSid: PropTypes.string, - // The default server name to use when the user hasn't specified - // one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this - // via `.well-known` discovery. The server name is used instead of the - // HS URL when talking about "your account". - defaultServerName: PropTypes.string, - // An error passed along from higher up explaining that something - // went wrong when finding the defaultHsUrl. - defaultServerDiscoveryError: PropTypes.string, - customHsUrl: PropTypes.string, - customIsUrl: PropTypes.string, - defaultHsUrl: PropTypes.string, - defaultIsUrl: PropTypes.string, + serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, brand: PropTypes.string, email: PropTypes.string, // registration shouldn't know or care how login is done. @@ -67,7 +58,7 @@ module.exports = React.createClass({ }, getInitialState: function() { - const serverType = ServerType.getTypeFromHsUrl(this.props.customHsUrl); + const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig); return { busy: false, @@ -88,11 +79,34 @@ module.exports = React.createClass({ // straight back into UI auth doingUIAuth: Boolean(this.props.sessionId), serverType, - hsUrl: this.props.customHsUrl, - isUrl: this.props.customIsUrl, // Phase of the overall registration dialog. phase: PHASE_REGISTRATION, flows: null, + // If set, we've registered but are not going to log + // the user in to their new account automatically. + completedNoSignin: false, + + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: true, + serverErrorIsFatal: false, + serverDeadError: "", + + // Our matrix client - part of state because we can't render the UI auth + // component without it. + matrixClient: null, + + // whether the HS requires an ID server to register with a threepid + serverRequiresIdServer: null, + + // The user ID we've just registered + registeredUsername: null, + + // if a different user ID to the one we just registered is logged in, + // this is the user ID that's logged in. + differentLoggedInUserId: null, }; }, @@ -101,18 +115,22 @@ module.exports = React.createClass({ this._replaceClient(); }, - onServerConfigChange: function(config) { - const newState = {}; - if (config.hsUrl !== undefined) { - newState.hsUrl = config.hsUrl; + componentWillReceiveProps(newProps) { + if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && + newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; + + this._replaceClient(newProps.serverConfig); + + // Handle cases where the user enters "https://matrix.org" for their server + // from the advanced option - we should default to FREE at that point. + const serverType = ServerType.getTypeFromServerConfig(newProps.serverConfig); + if (serverType !== this.state.serverType) { + // Reset the phase to default phase for the server type. + this.setState({ + serverType, + phase: this.getDefaultPhaseForServerType(serverType), + }); } - if (config.isUrl !== undefined) { - newState.isUrl = config.isUrl; - } - this.props.onServerConfigChange(config); - this.setState(newState, () => { - this._replaceClient(); - }); }, getDefaultPhaseForServerType(type) { @@ -137,19 +155,17 @@ module.exports = React.createClass({ // the new type. switch (type) { case ServerType.FREE: { - const { hsUrl, isUrl } = ServerType.TYPES.FREE; - this.onServerConfigChange({ - hsUrl, - isUrl, - }); + const { serverConfig } = ServerType.TYPES.FREE; + this.props.onServerConfigChange(serverConfig); break; } case ServerType.PREMIUM: + // We can accept whatever server config was the default here as this essentially + // acts as a slightly different "custom server"/ADVANCED option. + break; case ServerType.ADVANCED: - this.onServerConfigChange({ - hsUrl: this.props.defaultHsUrl, - isUrl: this.props.defaultIsUrl, - }); + // Use the default config from the config + this.props.onServerConfigChange(SdkConfig.get()["validated_server_config"]); break; } @@ -159,13 +175,54 @@ module.exports = React.createClass({ }); }, - _replaceClient: async function() { + _replaceClient: async function(serverConfig) { this.setState({ errorText: null, + serverDeadError: null, + serverErrorIsFatal: false, + // busy while we do liveness check (we need to avoid trying to render + // the UI auth component while we don't have a matrix client) + busy: true, }); - this._matrixClient = Matrix.createClient({ - baseUrl: this.state.hsUrl, - idBaseUrl: this.state.isUrl, + if (!serverConfig) serverConfig = this.props.serverConfig; + + // Do a liveliness check on the URLs + try { + await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( + serverConfig.hsUrl, + serverConfig.isUrl, + ); + this.setState({ + serverIsAlive: true, + serverErrorIsFatal: false, + }); + } catch (e) { + this.setState({ + busy: false, + ...AutoDiscoveryUtils.authComponentStateForError(e, "register"), + }); + if (this.state.serverErrorIsFatal) { + return; // Server is dead - do not continue. + } + } + + const {hsUrl, isUrl} = serverConfig; + const cli = Matrix.createClient({ + baseUrl: hsUrl, + idBaseUrl: isUrl, + }); + + let serverRequiresIdServer = true; + try { + serverRequiresIdServer = await cli.doesServerRequireIdServerParam(); + } catch (e) { + console.log("Unable to determine is server needs id_server param", e); + } + + this.setState({ + matrixClient: cli, + serverRequiresIdServer, + busy: false, }); try { await this._makeRegisterRequest({}); @@ -182,6 +239,7 @@ module.exports = React.createClass({ errorText: _t("Registration has been disabled on this homeserver."), }); } else { + console.log("Unable to query for supported registration methods.", e); this.setState({ errorText: _t("Unable to query for supported registration methods."), }); @@ -190,12 +248,6 @@ module.exports = React.createClass({ }, onFormSubmit: function(formVals) { - // Don't allow the user to register if there's a discovery error - // Without this, the user could end up registering on the wrong homeserver. - if (this.props.defaultServerDiscoveryError) { - this.setState({errorText: this.props.defaultServerDiscoveryError}); - return; - } this.setState({ errorText: "", busy: true, @@ -205,14 +257,14 @@ module.exports = React.createClass({ }, _requestEmailToken: function(emailAddress, clientSecret, sendAttempt, sessionId) { - return this._matrixClient.requestRegisterEmailToken( + return this.state.matrixClient.requestRegisterEmailToken( emailAddress, clientSecret, sendAttempt, this.props.makeRegistrationUrl({ client_secret: clientSecret, - hs_url: this._matrixClient.getHomeserverUrl(), - is_url: this._matrixClient.getIdentityServerUrl(), + hs_url: this.state.matrixClient.getHomeserverUrl(), + is_url: this.state.matrixClient.getIdentityServerUrl(), session_id: sessionId, }), ); @@ -222,7 +274,7 @@ module.exports = React.createClass({ if (!success) { let msg = response.message || response.toString(); // can we give a better error message? - if (response.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') { + if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const errorTop = messageForResourceLimitError( response.data.limit_type, response.data.admin_contact, { @@ -261,21 +313,47 @@ module.exports = React.createClass({ return; } - this.setState({ - // we're still busy until we get unmounted: don't show the registration form again - busy: true, + MatrixClientPeg.setJustRegisteredUserId(response.user_id); + + const newState = { doingUIAuth: false, - }); + registeredUsername: response.user_id, + }; - const cli = await this.props.onLoggedIn({ - userId: response.user_id, - deviceId: response.device_id, - homeserverUrl: this._matrixClient.getHomeserverUrl(), - identityServerUrl: this._matrixClient.getIdentityServerUrl(), - accessToken: response.access_token, - }); + // The user came in through an email validation link. To avoid overwriting + // their session, check to make sure the session isn't someone else, and + // isn't a guest user since we'll usually have set a guest user session before + // starting the registration process. This isn't perfect since it's possible + // the user had a separate guest session they didn't actually mean to replace. + const sessionOwner = Lifecycle.getStoredSessionOwner(); + const sessionIsGuest = Lifecycle.getStoredSessionIsGuest(); + if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) { + console.log( + `Found a session for ${sessionOwner} but ${response.userId} has just registered.`, + ); + newState.differentLoggedInUserId = sessionOwner; + } else { + newState.differentLoggedInUserId = null; + } - this._setupPushers(cli); + if (response.access_token) { + const cli = await this.props.onLoggedIn({ + userId: response.user_id, + deviceId: response.device_id, + homeserverUrl: this.state.matrixClient.getHomeserverUrl(), + identityServerUrl: this.state.matrixClient.getIdentityServerUrl(), + accessToken: response.access_token, + }); + + this._setupPushers(cli); + // we're still busy until we get unmounted: don't show the registration form again + newState.busy = true; + } else { + newState.busy = false; + newState.completedNoSignin = true; + } + + this.setState(newState); }, _setupPushers: function(matrixClient) { @@ -317,8 +395,7 @@ module.exports = React.createClass({ }); }, - onServerDetailsNextPhaseClick(ev) { - ev.stopPropagation(); + async onServerDetailsNextPhaseClick() { this.setState({ phase: PHASE_REGISTRATION, }); @@ -333,21 +410,25 @@ module.exports = React.createClass({ }, _makeRegisterRequest: function(auth) { - // Only send the bind params if we're sending username / pw params + // We inhibit login if we're trying to register with an email address: this + // avoids a lot of complex race conditions that can occur if we try to log + // the user in one one or both of the tabs they might end up with after + // clicking the email link. + let inhibitLogin = Boolean(this.state.formVals.email); + + // Only send inhibitLogin if we're sending username / pw params // (Since we need to send no params at all to use the ones saved in the // session). - const bindThreepids = this.state.formVals.password ? { - email: true, - msisdn: true, - } : {}; + if (!this.state.formVals.password) inhibitLogin = null; - return this._matrixClient.register( + return this.state.matrixClient.register( this.state.formVals.username, this.state.formVals.password, undefined, // session id: included in the auth dict already auth, - bindThreepids, null, + null, + inhibitLogin, ); }, @@ -359,11 +440,23 @@ module.exports = React.createClass({ }; }, + // Links to the login page shown after registration is completed are routed through this + // which checks the user hasn't already logged in somewhere else (perhaps we should do + // this more generally?) + _onLoginClickWithCheck: async function(ev) { + ev.preventDefault(); + + const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true}); + if (!sessionLoaded) { + // ok fine, there's still no session: really go to the login page + this.props.onLoginClick(); + } + }, + renderServerComponent() { const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector"); const ServerConfig = sdk.getComponent("auth.ServerConfig"); const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig"); - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); if (SdkConfig.get()['disable_custom_urls']) { return null; @@ -371,7 +464,9 @@ module.exports = React.createClass({ // If we're on a different phase, we only show the server type selector, // which is always shown if we allow custom URLs at all. - if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) { + // (if there's a fatal server error, we need to show the full server + // config as the user may need to change servers to resolve the error). + if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) { return
    ; } + const serverDetailsProps = {}; + if (PHASES_ENABLED) { + serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; + serverDetailsProps.submitText = _t("Next"); + serverDetailsProps.submitClass = "mx_Login_submit"; + } + let serverDetails = null; switch (this.state.serverType) { case ServerType.FREE: break; case ServerType.PREMIUM: serverDetails = ; break; case ServerType.ADVANCED: serverDetails = ; break; } - let nextButton = null; - if (PHASES_ENABLED) { - nextButton = - {_t("Next")} - ; - } - return
    {serverDetails} - {nextButton}
    ; }, @@ -433,9 +523,9 @@ module.exports = React.createClass({ const Spinner = sdk.getComponent('elements.Spinner'); const RegistrationForm = sdk.getComponent('auth.RegistrationForm'); - if (this.state.doingUIAuth) { + if (this.state.matrixClient && this.state.doingUIAuth) { return ; + } else if (!this.state.matrixClient && !this.state.busy) { + return null; } else if (this.state.busy || !this.state.flows) { return
    @@ -461,13 +553,6 @@ module.exports = React.createClass({ onEditServerDetailsClick = this.onEditServerDetailsClick; } - // If the current HS URL is the default HS URL, then we can label it - // with the default HS name (if it exists). - let hsName; - if (this.state.hsUrl === this.props.defaultHsUrl) { - hsName = this.props.defaultServerName; - } - return ; } }, @@ -487,13 +573,28 @@ module.exports = React.createClass({ const AuthHeader = sdk.getComponent('auth.AuthHeader'); const AuthBody = sdk.getComponent("auth.AuthBody"); const AuthPage = sdk.getComponent('auth.AuthPage'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); let errorText; - const err = this.state.errorText || this.props.defaultServerDiscoveryError; + const err = this.state.errorText; if (err) { errorText =
    { err }
    ; } + let serverDeadSection; + if (!this.state.serverIsAlive) { + const classes = classNames({ + "mx_Login_error": true, + "mx_Login_serverError": true, + "mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal, + }); + serverDeadSection = ( +
    + {this.state.serverDeadError} +
    + ); + } + const signIn =
    { _t('Sign in instead') } ; @@ -506,16 +607,62 @@ module.exports = React.createClass({ ; } + let body; + if (this.state.completedNoSignin) { + let regDoneText; + if (this.state.differentLoggedInUserId) { + regDoneText =
    +

    {_t( + "Your new account (%(newAccountId)s) is registered, but you're already " + + "logged into a different account (%(loggedInUserId)s).", { + newAccountId: this.state.registeredUsername, + loggedInUserId: this.state.differentLoggedInUserId, + }, + )}

    +

    + {_t("Continue with previous account")} +

    +
    ; + } else if (this.state.formVals.password) { + // We're the client that started the registration + regDoneText =

    {_t( + "Log in to your new account.", {}, + { + a: (sub) => {sub}, + }, + )}

    ; + } else { + // We're not the original client: the user probably got to us by clicking the + // email validation link. We can't offer a 'go straight to your account' link + // as we don't have the original creds. + regDoneText =

    {_t( + "You can now close this window or log in to your new account.", {}, + { + a: (sub) => {sub}, + }, + )}

    ; + } + body =
    +

    {_t("Registration Successful")}

    + { regDoneText } +
    ; + } else { + body =
    +

    { _t('Create your account') }

    + { errorText } + { serverDeadSection } + { this.renderServerComponent() } + { this.renderRegisterComponent() } + { goBack } + { signIn } +
    ; + } + return ( -

    { _t('Create your account') }

    - { errorText } - { this.renderServerComponent() } - { this.renderRegisterComponent() } - { goBack } - { signIn } + { body }
    ); diff --git a/src/components/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.js new file mode 100644 index 0000000000..585b4bfe67 --- /dev/null +++ b/src/components/structures/auth/SoftLogout.js @@ -0,0 +1,322 @@ +/* +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 React from 'react'; +import PropTypes from 'prop-types'; +import {_t} from '../../../languageHandler'; +import sdk from '../../../index'; +import dis from '../../../dispatcher'; +import * as Lifecycle from '../../../Lifecycle'; +import Modal from '../../../Modal'; +import MatrixClientPeg from "../../../MatrixClientPeg"; +import {sendLoginRequest} from "../../../Login"; +import url from 'url'; + +const LOGIN_VIEW = { + LOADING: 1, + PASSWORD: 2, + CAS: 3, // SSO, but old + SSO: 4, + UNSUPPORTED: 5, +}; + +const FLOWS_TO_VIEWS = { + "m.login.password": LOGIN_VIEW.PASSWORD, + "m.login.cas": LOGIN_VIEW.CAS, + "m.login.sso": LOGIN_VIEW.SSO, +}; + +export default class SoftLogout extends React.Component { + static propTypes = { + // Query parameters from MatrixChat + realQueryParams: PropTypes.object, // {homeserver, identityServer, loginToken} + + // Called when the SSO login completes + onTokenLoginCompleted: PropTypes.func, + }; + + constructor() { + super(); + + this.state = { + loginView: LOGIN_VIEW.LOADING, + keyBackupNeeded: true, // assume we do while we figure it out (see componentWillMount) + ssoUrl: null, + + busy: false, + password: "", + errorText: "", + }; + } + + componentDidMount(): void { + // We've ended up here when we don't need to - navigate to login + if (!Lifecycle.isSoftLogout()) { + dis.dispatch({action: "on_logged_in"}); + return; + } + + this._initLogin(); + + MatrixClientPeg.get().flagAllGroupSessionsForBackup().then(remaining => { + this.setState({keyBackupNeeded: remaining > 0}); + }); + } + + onClearAll = () => { + const ConfirmWipeDeviceDialog = sdk.getComponent('dialogs.ConfirmWipeDeviceDialog'); + Modal.createTrackedDialog('Clear Data', 'Soft Logout', ConfirmWipeDeviceDialog, { + onFinished: (wipeData) => { + if (!wipeData) return; + + console.log("Clearing data from soft-logged-out device"); + Lifecycle.logout(); + }, + }); + }; + + async _initLogin() { + const queryParams = this.props.realQueryParams; + const hasAllParams = queryParams && queryParams['homeserver'] && queryParams['loginToken']; + if (hasAllParams) { + this.setState({loginView: LOGIN_VIEW.LOADING}); + this.trySsoLogin(); + return; + } + + // Note: we don't use the existing Login class because it is heavily flow-based. We don't + // care about login flows here, unless it is the single flow we support. + const client = MatrixClientPeg.get(); + const loginViews = (await client.loginFlows()).flows.map(f => FLOWS_TO_VIEWS[f.type]); + + const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED; + this.setState({loginView: chosenView}); + + if (chosenView === LOGIN_VIEW.CAS || chosenView === LOGIN_VIEW.SSO) { + const client = MatrixClientPeg.get(); + + const appUrl = url.parse(window.location.href, true); + appUrl.hash = ""; // Clear #/soft_logout off the URL + appUrl.query["homeserver"] = client.getHomeserverUrl(); + appUrl.query["identityServer"] = client.getIdentityServerUrl(); + + const ssoUrl = client.getSsoLoginUrl(url.format(appUrl), chosenView === LOGIN_VIEW.CAS ? "cas" : "sso"); + this.setState({ssoUrl}); + } + } + + onPasswordChange = (ev) => { + this.setState({password: ev.target.value}); + }; + + onForgotPassword = () => { + dis.dispatch({action: 'start_password_recovery'}); + }; + + onPasswordLogin = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + this.setState({busy: true}); + + const hsUrl = MatrixClientPeg.get().getHomeserverUrl(); + const isUrl = MatrixClientPeg.get().getIdentityServerUrl(); + const loginType = "m.login.password"; + const loginParams = { + identifier: { + type: "m.id.user", + user: MatrixClientPeg.get().getUserId(), + }, + password: this.state.password, + device_id: MatrixClientPeg.get().getDeviceId(), + }; + + let credentials = null; + try { + credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams); + } catch (e) { + let errorText = _t("Failed to re-authenticate due to a homeserver problem"); + if (e.errcode === "M_FORBIDDEN" && (e.httpStatus === 401 || e.httpStatus === 403)) { + errorText = _t("Incorrect password"); + } + + this.setState({ + busy: false, + errorText: errorText, + }); + return; + } + + Lifecycle.hydrateSession(credentials).catch((e) => { + console.error(e); + this.setState({busy: false, errorText: _t("Failed to re-authenticate")}); + }); + }; + + async trySsoLogin() { + this.setState({busy: true}); + + const hsUrl = this.props.realQueryParams['homeserver']; + const isUrl = this.props.realQueryParams['identityServer'] || MatrixClientPeg.get().getIdentityServerUrl(); + const loginType = "m.login.token"; + const loginParams = { + token: this.props.realQueryParams['loginToken'], + device_id: MatrixClientPeg.get().getDeviceId(), + }; + + let credentials = null; + try { + credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams); + } catch (e) { + console.error(e); + this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED}); + return; + } + + Lifecycle.hydrateSession(credentials).then(() => { + if (this.props.onTokenLoginCompleted) this.props.onTokenLoginCompleted(); + }).catch((e) => { + console.error(e); + this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED}); + }); + } + + onSsoLogin = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + this.setState({busy: true}); + window.location.href = this.state.ssoUrl; + }; + + _renderSignInSection() { + if (this.state.loginView === LOGIN_VIEW.LOADING) { + const Spinner = sdk.getComponent("elements.Spinner"); + return ; + } + + let introText = null; // null is translated to something area specific in this function + if (this.state.keyBackupNeeded) { + introText = _t( + "Regain access to your account and recover encryption keys stored on this device. " + + "Without them, you won’t be able to read all of your secure messages on any device."); + } + + if (this.state.loginView === LOGIN_VIEW.PASSWORD) { + const Field = sdk.getComponent("elements.Field"); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + + let error = null; + if (this.state.errorText) { + error = {this.state.errorText}; + } + + if (!introText) { + introText = _t("Enter your password to sign in and regain access to your account."); + } // else we already have a message and should use it (key backup warning) + + return ( +
    +

    {introText}

    + {error} + + + {_t("Sign In")} + + + {_t("Forgotten your password?")} + + + ); + } + + if (this.state.loginView === LOGIN_VIEW.SSO || this.state.loginView === LOGIN_VIEW.CAS) { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + + if (!introText) { + introText = _t("Sign in and regain access to your account."); + } // else we already have a message and should use it (key backup warning) + + return ( +
    +

    {introText}

    + + {_t('Sign in with single sign-on')} + +
    + ); + } + + // Default: assume unsupported/error + return ( +

    + {_t( + "You cannot sign in to your account. Please contact your " + + "homeserver admin for more information.", + )} +

    + ); + } + + render() { + const AuthPage = sdk.getComponent("auth.AuthPage"); + const AuthHeader = sdk.getComponent("auth.AuthHeader"); + const AuthBody = sdk.getComponent("auth.AuthBody"); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + + return ( + + + +

    + {_t("You're signed out")} +

    + +

    {_t("Sign in")}

    +
    + {this._renderSignInSection()} +
    + +

    {_t("Clear personal data")}

    +

    + {_t( + "Warning: Your personal data (including encryption keys) is still stored " + + "on this device. Clear it if you're finished using this device, or want to sign " + + "in to another account.", + )} +

    +
    + + {_t("Clear all data")} + +
    +
    +
    + ); + } +} diff --git a/src/components/views/auth/CountryDropdown.js b/src/components/views/auth/CountryDropdown.js index b29bf40625..d8aa88c798 100644 --- a/src/components/views/auth/CountryDropdown.js +++ b/src/components/views/auth/CountryDropdown.js @@ -20,6 +20,7 @@ import PropTypes from 'prop-types'; import sdk from '../../../index'; import { COUNTRIES } from '../../../phonenumber'; +import SdkConfig from "../../../SdkConfig"; const COUNTRIES_BY_ISO2 = {}; for (const c of COUNTRIES) { @@ -45,17 +46,25 @@ export default class CountryDropdown extends React.Component { this._onOptionChange = this._onOptionChange.bind(this); this._getShortOption = this._getShortOption.bind(this); + let defaultCountry = COUNTRIES[0]; + const defaultCountryCode = SdkConfig.get()["defaultCountryCode"]; + if (defaultCountryCode) { + const country = COUNTRIES.find(c => c.iso2 === defaultCountryCode.toUpperCase()); + if (country) defaultCountry = country; + } + this.state = { searchQuery: '', + defaultCountry, }; } componentWillMount() { if (!this.props.value) { - // If no value is given, we start with the first + // If no value is given, we start with the default // country selected, but our parent component // doesn't know this, therefore we do this. - this.props.onOptionChange(COUNTRIES[0]); + this.props.onOptionChange(this.state.defaultCountry); } } @@ -119,7 +128,7 @@ export default class CountryDropdown extends React.Component { // default value here too, otherwise we need to handle null / undefined // values between mounting and the initial value propgating - const value = this.props.value || COUNTRIES[0].iso2; + const value = this.props.value || this.state.defaultCountry.iso2; return ); } @@ -138,17 +136,21 @@ export const PasswordAuthEntry = React.createClass({ ); } + const Field = sdk.getComponent('elements.Field'); + return (

    { _t("To continue, please enter your password.") }

    -
    - - +
    { submitButtonOrSpinner } @@ -467,11 +469,18 @@ export const MsisdnAuthEntry = React.createClass({ ); this.props.submitAuthDict({ type: MsisdnAuthEntry.LOGIN_TYPE, + // TODO: Remove `threepid_creds` once servers support proper UIA + // See https://github.com/vector-im/riot-web/issues/10312 threepid_creds: { sid: this._sid, client_secret: this.props.clientSecret, id_server: idServerParsedUrl.host, }, + threepidCreds: { + sid: this._sid, + client_secret: this.props.clientSecret, + id_server: idServerParsedUrl.host, + }, }); } else { this.setState({ diff --git a/src/components/views/auth/ModularServerConfig.js b/src/components/views/auth/ModularServerConfig.js index 9c6c4b01bf..ff8d88f738 100644 --- a/src/components/views/auth/ModularServerConfig.js +++ b/src/components/views/auth/ModularServerConfig.js @@ -15,91 +15,82 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; +import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import SdkConfig from "../../../SdkConfig"; +import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; +import * as ServerType from '../../views/auth/ServerTypeSelector'; +import ServerConfig from "./ServerConfig"; const MODULAR_URL = 'https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication'; +// TODO: TravisR - Can this extend ServerConfig for most things? + /* * Configure the Modular server name. * * This is a variant of ServerConfig with only the HS field and different body * text that is specific to the Modular case. */ -export default class ModularServerConfig extends React.PureComponent { - static propTypes = { - onServerConfigChange: PropTypes.func, +export default class ModularServerConfig extends ServerConfig { + static propTypes = ServerConfig.propTypes; - // default URLs are defined in config.json (or the hardcoded defaults) - // they are used if the user has not overridden them with a custom URL. - // In other words, if the custom URL is blank, the default is used. - defaultHsUrl: PropTypes.string, // e.g. https://matrix.org - - // This component always uses the default IS URL and doesn't allow it - // to be changed. We still receive it as a prop here to simplify - // consumers by still passing the IS URL via onServerConfigChange. - defaultIsUrl: PropTypes.string, // e.g. https://vector.im - - // custom URLs are explicitly provided by the user and override the - // default URLs. The user enters them via the component's input fields, - // which is reflected on these properties whenever on..UrlChanged fires. - // They are persisted in localStorage by MatrixClientPeg, and so can - // override the default URLs when the component initially loads. - customHsUrl: PropTypes.string, - - delayTimeMs: PropTypes.number, // time to wait before invoking onChanged - } - - static defaultProps = { - onServerConfigChange: function() {}, - customHsUrl: "", - delayTimeMs: 0, - } - - constructor(props) { - super(props); - - this.state = { - hsUrl: props.customHsUrl, - }; - } - - componentWillReceiveProps(newProps) { - if (newProps.customHsUrl === this.state.hsUrl) return; + async validateAndApplyServer(hsUrl, isUrl) { + // Always try and use the defaults first + const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"]; + if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) { + this.setState({busy: false, errorText: ""}); + this.props.onServerConfigChange(defaultConfig); + return defaultConfig; + } this.setState({ - hsUrl: newProps.customHsUrl, + hsUrl, + isUrl, + busy: true, + errorText: "", }); - this.props.onServerConfigChange({ - hsUrl: newProps.customHsUrl, - isUrl: this.props.defaultIsUrl, - }); - } - onHomeserverBlur = (ev) => { - this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => { - this.props.onServerConfigChange({ - hsUrl: this.state.hsUrl, - isUrl: this.props.defaultIsUrl, + try { + const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); + this.setState({busy: false, errorText: ""}); + this.props.onServerConfigChange(result); + return result; + } catch (e) { + console.error(e); + let message = _t("Unable to validate homeserver/identity server"); + if (e.translatedMessage) { + message = e.translatedMessage; + } + this.setState({ + busy: false, + errorText: message, }); - }); - } - onHomeserverChange = (ev) => { - const hsUrl = ev.target.value; - this.setState({ hsUrl }); - } - - _waitThenInvoke(existingTimeoutId, fn) { - if (existingTimeoutId) { - clearTimeout(existingTimeoutId); + return null; } - return setTimeout(fn.bind(this), this.props.delayTimeMs); + } + + async validateServer() { + // TODO: Do we want to support .well-known lookups here? + // If for some reason someone enters "matrix.org" for a URL, we could do a lookup to + // find their homeserver without demanding they use "https://matrix.org" + return this.validateAndApplyServer(this.state.hsUrl, ServerType.TYPES.PREMIUM.identityServerUrl); } render() { const Field = sdk.getComponent('elements.Field'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + + const submitButton = this.props.submitText + ? {this.props.submitText} + : null; return (
    @@ -113,15 +104,18 @@ export default class ModularServerConfig extends React.PureComponent { , }, )} -
    - -
    + +
    + +
    + {submitButton} +
    ); } diff --git a/src/components/views/auth/PasswordLogin.js b/src/components/views/auth/PasswordLogin.js index ed3afede2f..59acf0a034 100644 --- a/src/components/views/auth/PasswordLogin.js +++ b/src/components/views/auth/PasswordLogin.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +Copyright 2019 New Vector Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,11 +22,29 @@ import classNames from 'classnames'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; +import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; /** * A pure UI component which displays a username/password form. */ -class PasswordLogin extends React.Component { +export default class PasswordLogin extends React.Component { + static propTypes = { + onSubmit: PropTypes.func.isRequired, // fn(username, password) + onError: PropTypes.func, + onForgotPasswordClick: PropTypes.func, // fn() + initialUsername: PropTypes.string, + initialPhoneCountry: PropTypes.string, + initialPhoneNumber: PropTypes.string, + initialPassword: PropTypes.string, + onUsernameChanged: PropTypes.func, + onPhoneCountryChanged: PropTypes.func, + onPhoneNumberChanged: PropTypes.func, + onPasswordChanged: PropTypes.func, + loginIncorrect: PropTypes.bool, + disableSubmit: PropTypes.bool, + serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, + }; + static defaultProps = { onError: function() {}, onEditServerDetailsClick: null, @@ -40,13 +59,12 @@ class PasswordLogin extends React.Component { initialPhoneNumber: "", initialPassword: "", loginIncorrect: false, - // This is optional and only set if we used a server name to determine - // the HS URL via `.well-known` discovery. The server name is used - // instead of the HS URL when talking about where to "sign in to". - hsName: null, - hsUrl: "", disableSubmit: false, - } + }; + + static LOGIN_FIELD_EMAIL = "login_field_email"; + static LOGIN_FIELD_MXID = "login_field_mxid"; + static LOGIN_FIELD_PHONE = "login_field_phone"; constructor(props) { super(props); @@ -193,10 +211,7 @@ class PasswordLogin extends React.Component { name="username" // make it a little easier for browser's remember-password key="username_input" type="text" - label={SdkConfig.get().disable_custom_urls ? - _t("Username on %(hs)s", { - hs: this.props.hsUrl.replace(/^https?:\/\//, ''), - }) : _t("Username")} + label={_t("Username")} value={this.state.username} onChange={this.onUsernameChanged} onBlur={this.onUsernameBlur} @@ -258,20 +273,22 @@ class PasswordLogin extends React.Component { ; } - let signInToText = _t('Sign in to your Matrix account'); - if (this.props.hsName) { - signInToText = _t('Sign in to your Matrix account on %(serverName)s', { - serverName: this.props.hsName, + let signInToText = _t('Sign in to your Matrix account on %(serverName)s', { + serverName: this.props.serverConfig.hsName, + }); + if (this.props.serverConfig.hsNameIsDifferent) { + const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); + + signInToText = _t('Sign in to your Matrix account on ', {}, { + 'underlinedServerName': () => { + return ; + }, }); - } else { - try { - const parsedHsUrl = new URL(this.props.hsUrl); - signInToText = _t('Sign in to your Matrix account on %(serverName)s', { - serverName: parsedHsUrl.hostname, - }); - } catch (e) { - // ignore - } } let editLink = null; @@ -295,7 +312,6 @@ class PasswordLogin extends React.Component {
    Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -26,6 +27,7 @@ import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import { SAFE_LOCALPART_REGEX } from '../../../Registration'; import withValidation from '../elements/Validation'; +import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; const FIELD_EMAIL = 'field_email'; const FIELD_PHONE_NUMBER = 'field_phone_number'; @@ -51,16 +53,15 @@ module.exports = React.createClass({ onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise onEditServerDetailsClick: PropTypes.func, flows: PropTypes.arrayOf(PropTypes.object).isRequired, - // This is optional and only set if we used a server name to determine - // the HS URL via `.well-known` discovery. The server name is used - // instead of the HS URL when talking about "your account". - hsName: PropTypes.string, - hsUrl: PropTypes.string, + serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, + canSubmit: PropTypes.bool, + serverRequiresIdServer: PropTypes.bool, }, getDefaultProps: function() { return { onValidationChange: console.error, + canSubmit: true, }; }, @@ -70,10 +71,10 @@ module.exports = React.createClass({ fieldValid: {}, // The ISO2 country code selected in the phone number entry phoneCountry: this.props.defaultPhoneCountry, - username: "", - email: "", - phoneNumber: "", - password: "", + username: this.props.defaultUsername || "", + email: this.props.defaultEmail || "", + phoneNumber: this.props.defaultPhoneNumber || "", + password: this.props.defaultPassword || "", passwordConfirm: "", passwordComplexity: null, passwordSafe: false, @@ -83,21 +84,34 @@ module.exports = React.createClass({ onSubmit: async function(ev) { ev.preventDefault(); + if (!this.props.canSubmit) return; + const allFieldsValid = await this.verifyFieldsBeforeSubmit(); if (!allFieldsValid) { return; } const self = this; - if (this.state.email == '') { + if (this.state.email === '') { + const haveIs = Boolean(this.props.serverConfig.isUrl); + + let desc; + if (haveIs) { + desc = _t( + "If you don't specify an email address, you won't be able to reset your password. " + + "Are you sure?", + ); + } else { + desc = _t( + "No Identity Server is configured so you cannot add add an email address in order to " + + "reset your password in the future.", + ); + } + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, { title: _t("Warning!"), - description: -
    - { _t("If you don't specify an email address, you won't be able to reset your password. " + - "Are you sure?") } -
    , + description: desc, button: _t("Continue"), onFinished: function(confirmed) { if (confirmed) { @@ -383,7 +397,7 @@ module.exports = React.createClass({ }, validateUsernameRules: withValidation({ - description: () => _t("Use letters, numbers, dashes and underscores only"), + description: () => _t("Use lowercase letters, numbers, dashes and underscores only"), rules: [ { key: "required", @@ -422,8 +436,25 @@ module.exports = React.createClass({ }); }, + _showEmail() { + const haveIs = Boolean(this.props.serverConfig.isUrl); + if ((this.props.serverRequiresIdServer && !haveIs) || !this._authStepIsUsed('m.login.email.identity')) { + return false; + } + return true; + }, + + _showPhoneNumber() { + const threePidLogin = !SdkConfig.get().disable_3pid_login; + const haveIs = Boolean(this.props.serverConfig.isUrl); + if (!threePidLogin || !haveIs || !this._authStepIsUsed('m.login.msisdn')) { + return false; + } + return true; + }, + renderEmail() { - if (!this._authStepIsUsed('m.login.email.identity')) { + if (!this._showEmail()) { return null; } const Field = sdk.getComponent('elements.Field'); @@ -435,7 +466,6 @@ module.exports = React.createClass({ ref={field => this[FIELD_EMAIL] = field} type="text" label={emailPlaceholder} - defaultValue={this.props.defaultEmail} value={this.state.email} onChange={this.onEmailChange} onValidate={this.onEmailValidate} @@ -449,7 +479,6 @@ module.exports = React.createClass({ ref={field => this[FIELD_PASSWORD] = field} type="password" label={_t("Password")} - defaultValue={this.props.defaultPassword} value={this.state.password} onChange={this.onPasswordChange} onValidate={this.onPasswordValidate} @@ -463,7 +492,6 @@ module.exports = React.createClass({ ref={field => this[FIELD_PASSWORD_CONFIRM] = field} type="password" label={_t("Confirm")} - defaultValue={this.props.defaultPassword} value={this.state.passwordConfirm} onChange={this.onPasswordConfirmChange} onValidate={this.onPasswordConfirmValidate} @@ -471,8 +499,7 @@ module.exports = React.createClass({ }, renderPhoneNumber() { - const threePidLogin = !SdkConfig.get().disable_3pid_login; - if (!threePidLogin || !this._authStepIsUsed('m.login.msisdn')) { + if (!this._showPhoneNumber()) { return null; } const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); @@ -491,7 +518,6 @@ module.exports = React.createClass({ ref={field => this[FIELD_PHONE_NUMBER] = field} type="text" label={phoneLabel} - defaultValue={this.props.defaultPhoneNumber} value={this.state.phoneNumber} prefix={phoneCountry} onChange={this.onPhoneNumberChange} @@ -507,7 +533,6 @@ module.exports = React.createClass({ type="text" autoFocus={true} label={_t("Username")} - defaultValue={this.props.defaultUsername} value={this.state.username} onChange={this.onUsernameChange} onValidate={this.onUsernameValidate} @@ -515,20 +540,22 @@ module.exports = React.createClass({ }, render: function() { - let yourMatrixAccountText = _t('Create your Matrix account'); - if (this.props.hsName) { - yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { - serverName: this.props.hsName, + let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { + serverName: this.props.serverConfig.hsName, + }); + if (this.props.serverConfig.hsNameIsDifferent) { + const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); + + yourMatrixAccountText = _t('Create your Matrix account on ', {}, { + 'underlinedServerName': () => { + return ; + }, }); - } else { - try { - const parsedHsUrl = new URL(this.props.hsUrl); - yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { - serverName: parsedHsUrl.hostname, - }); - } catch (e) { - // ignore - } } let editLink = null; @@ -541,9 +568,35 @@ module.exports = React.createClass({ } const registerButton = ( - + ); + let emailHelperText = null; + if (this._showEmail()) { + if (this._showPhoneNumber()) { + emailHelperText =
    + {_t( + "Set an email for account recovery. " + + "Use email or phone to optionally be discoverable by existing contacts.", + )} +
    ; + } else { + emailHelperText =
    + {_t( + "Set an email for account recovery. " + + "Use email to optionally be discoverable by existing contacts.", + )} +
    ; + } + } + const haveIs = Boolean(this.props.serverConfig.isUrl); + const noIsText = haveIs ? null :
    + {_t( + "No Identity Server is configured: no email addreses can be added. " + + "You will be unable to reset your password.", + )} +
    ; + return (

    @@ -562,8 +615,8 @@ module.exports = React.createClass({ {this.renderEmail()} {this.renderPhoneNumber()}

    - {_t("Use an email address to recover your account.") + " "} - {_t("Other users can invite you to rooms using your contact details.")} + { emailHelperText } + { noIsText } { registerButton }
    diff --git a/src/components/views/auth/ServerConfig.js b/src/components/views/auth/ServerConfig.js index cb0e0dc38e..81777abb73 100644 --- a/src/components/views/auth/ServerConfig.js +++ b/src/components/views/auth/ServerConfig.js @@ -1,6 +1,7 @@ /* Copyright 2015, 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. @@ -20,6 +21,11 @@ import PropTypes from 'prop-types'; import Modal from '../../../Modal'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; +import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; +import SdkConfig from "../../../SdkConfig"; +import { createClient } from 'matrix-js-sdk/lib/matrix'; +import classNames from 'classnames'; /* * A pure UI component which displays the HS and IS to use. @@ -27,82 +33,175 @@ import { _t } from '../../../languageHandler'; export default class ServerConfig extends React.PureComponent { static propTypes = { - onServerConfigChange: PropTypes.func, + onServerConfigChange: PropTypes.func.isRequired, - // default URLs are defined in config.json (or the hardcoded defaults) - // they are used if the user has not overridden them with a custom URL. - // In other words, if the custom URL is blank, the default is used. - defaultHsUrl: PropTypes.string, // e.g. https://matrix.org - defaultIsUrl: PropTypes.string, // e.g. https://vector.im - - // custom URLs are explicitly provided by the user and override the - // default URLs. The user enters them via the component's input fields, - // which is reflected on these properties whenever on..UrlChanged fires. - // They are persisted in localStorage by MatrixClientPeg, and so can - // override the default URLs when the component initially loads. - customHsUrl: PropTypes.string, - customIsUrl: PropTypes.string, + // The current configuration that the user is expecting to change. + serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, delayTimeMs: PropTypes.number, // time to wait before invoking onChanged - } + + // Called after the component calls onServerConfigChange + onAfterSubmit: PropTypes.func, + + // Optional text for the submit button. If falsey, no button will be shown. + submitText: PropTypes.string, + + // Optional class for the submit button. Only applies if the submit button + // is to be rendered. + submitClass: PropTypes.string, + + // Whether the flow this component is embedded in requires an identity + // server when the homeserver says it will need one. Default false. + showIdentityServerIfRequiredByHomeserver: PropTypes.bool, + }; static defaultProps = { onServerConfigChange: function() {}, - customHsUrl: "", - customIsUrl: "", delayTimeMs: 0, - } + }; constructor(props) { super(props); this.state = { - hsUrl: props.customHsUrl, - isUrl: props.customIsUrl, + busy: false, + errorText: "", + hsUrl: props.serverConfig.hsUrl, + isUrl: props.serverConfig.isUrl, + showIdentityServer: false, }; } componentWillReceiveProps(newProps) { - if (newProps.customHsUrl === this.state.hsUrl && - newProps.customIsUrl === this.state.isUrl) return; + if (newProps.serverConfig.hsUrl === this.state.hsUrl && + newProps.serverConfig.isUrl === this.state.isUrl) return; + + this.validateAndApplyServer(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl); + } + + async validateServer() { + // TODO: Do we want to support .well-known lookups here? + // If for some reason someone enters "matrix.org" for a URL, we could do a lookup to + // find their homeserver without demanding they use "https://matrix.org" + const result = this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl); + if (!result) { + return result; + } + + // If the UI flow this component is embedded in requires an identity + // server when the homeserver says it will need one, check first and + // reveal this field if not already shown. + // XXX: This a backward compatibility path for homeservers that require + // an identity server to be passed during certain flows. + // See also https://github.com/matrix-org/synapse/pull/5868. + if ( + this.props.showIdentityServerIfRequiredByHomeserver && + !this.state.showIdentityServer && + await this.isIdentityServerRequiredByHomeserver() + ) { + this.setState({ + showIdentityServer: true, + }); + return null; + } + + return result; + } + + async validateAndApplyServer(hsUrl, isUrl) { + // Always try and use the defaults first + const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"]; + if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) { + this.setState({ + hsUrl: defaultConfig.hsUrl, + isUrl: defaultConfig.isUrl, + busy: false, + errorText: "", + }); + this.props.onServerConfigChange(defaultConfig); + return defaultConfig; + } this.setState({ - hsUrl: newProps.customHsUrl, - isUrl: newProps.customIsUrl, - }); - this.props.onServerConfigChange({ - hsUrl: newProps.customHsUrl, - isUrl: newProps.customIsUrl, + hsUrl, + isUrl, + busy: true, + errorText: "", }); + + try { + const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); + this.setState({busy: false, errorText: ""}); + this.props.onServerConfigChange(result); + return result; + } catch (e) { + console.error(e); + + const stateForError = AutoDiscoveryUtils.authComponentStateForError(e); + if (!stateForError.isFatalError) { + this.setState({ + busy: false, + }); + // carry on anyway + const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl, true); + this.props.onServerConfigChange(result); + return result; + } else { + let message = _t("Unable to validate homeserver/identity server"); + if (e.translatedMessage) { + message = e.translatedMessage; + } + this.setState({ + busy: false, + errorText: message, + }); + + return null; + } + } + } + + async isIdentityServerRequiredByHomeserver() { + // XXX: We shouldn't have to create a whole new MatrixClient just to + // check if the homeserver requires an identity server... Should it be + // extracted to a static utils function...? + return createClient({ + baseUrl: this.state.hsUrl, + }).doesServerRequireIdServerParam(); } onHomeserverBlur = (ev) => { this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => { - this.props.onServerConfigChange({ - hsUrl: this.state.hsUrl, - isUrl: this.state.isUrl, - }); + this.validateServer(); }); - } + }; onHomeserverChange = (ev) => { const hsUrl = ev.target.value; this.setState({ hsUrl }); - } + }; onIdentityServerBlur = (ev) => { this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, () => { - this.props.onServerConfigChange({ - hsUrl: this.state.hsUrl, - isUrl: this.state.isUrl, - }); + this.validateServer(); }); - } + }; onIdentityServerChange = (ev) => { const isUrl = ev.target.value; this.setState({ isUrl }); - } + }; + + onSubmit = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const result = await this.validateServer(); + if (!result) return; // Do not continue. + + if (this.props.onAfterSubmit) { + this.props.onAfterSubmit(); + } + }; _waitThenInvoke(existingTimeoutId, fn) { if (existingTimeoutId) { @@ -114,35 +213,75 @@ export default class ServerConfig extends React.PureComponent { showHelpPopup = () => { const CustomServerDialog = sdk.getComponent('auth.CustomServerDialog'); Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog); + }; + + _renderHomeserverSection() { + const Field = sdk.getComponent('elements.Field'); + return
    + {_t("Enter your custom homeserver URL What does this mean?", {}, { + a: sub => + {sub} + , + })} + +
    ; + } + + _renderIdentityServerSection() { + const Field = sdk.getComponent('elements.Field'); + const classes = classNames({ + "mx_ServerConfig_identityServer": true, + "mx_ServerConfig_identityServer_shown": this.state.showIdentityServer, + }); + return
    + {_t("Enter your custom identity server URL What does this mean?", {}, { + a: sub => + {sub} + , + })} + +
    ; } render() { - const Field = sdk.getComponent('elements.Field'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + + const errorText = this.state.errorText + ? {this.state.errorText} + : null; + + const submitButton = this.props.submitText + ? {this.props.submitText} + : null; return (

    {_t("Other servers")}

    - {_t("Enter custom server URLs What does this mean?", {}, { - a: sub => - { sub } - , - })} -
    - - -
    + {errorText} + {this._renderHomeserverSection()} + {this._renderIdentityServerSection()} +
    + {submitButton} +
    ); } diff --git a/src/components/views/auth/ServerTypeSelector.js b/src/components/views/auth/ServerTypeSelector.js index 71d13da421..fa76bc8512 100644 --- a/src/components/views/auth/ServerTypeSelector.js +++ b/src/components/views/auth/ServerTypeSelector.js @@ -19,6 +19,8 @@ import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import sdk from '../../../index'; import classnames from 'classnames'; +import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import {makeType} from "../../../utils/TypeUtils"; const MODULAR_URL = 'https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication'; @@ -32,8 +34,12 @@ export const TYPES = { label: () => _t('Free'), logo: () => , description: () => _t('Join millions for free on the largest public server'), - hsUrl: 'https://matrix.org', - isUrl: 'https://vector.im', + serverConfig: makeType(ValidatedServerConfig, { + hsUrl: "https://matrix.org", + hsName: "matrix.org", + hsNameIsDifferent: false, + isUrl: "https://vector.im", + }), }, PREMIUM: { id: PREMIUM, @@ -44,6 +50,7 @@ export const TYPES = { {sub} , }), + identityServerUrl: "https://vector.im", }, ADVANCED: { id: ADVANCED, @@ -56,10 +63,11 @@ export const TYPES = { }, }; -export function getTypeFromHsUrl(hsUrl) { +export function getTypeFromServerConfig(config) { + const {hsUrl} = config; if (!hsUrl) { return null; - } else if (hsUrl === TYPES.FREE.hsUrl) { + } else if (hsUrl === TYPES.FREE.serverConfig.hsUrl) { return FREE; } else if (new URL(hsUrl).hostname.endsWith('.modular.im')) { // This is an unlikely case to reach, as Modular defaults to hiding the @@ -76,7 +84,7 @@ export default class ServerTypeSelector extends React.PureComponent { selected: PropTypes.string, // Handler called when the selected type changes. onChange: PropTypes.func.isRequired, - } + }; constructor(props) { super(props); @@ -106,7 +114,7 @@ export default class ServerTypeSelector extends React.PureComponent { e.stopPropagation(); const type = e.currentTarget.dataset.id; this.updateSelectedType(type); - } + }; render() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js index 5b299c2570..afc6faa18d 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket 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. @@ -19,7 +20,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk'; import AvatarLogic from '../../../Avatar'; -import sdk from '../../../index'; +import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from '../elements/AccessibleButton'; module.exports = React.createClass({ @@ -104,9 +105,13 @@ module.exports = React.createClass({ // work out the full set of urls to try to load. This is formed like so: // imageUrls: [ props.url, props.urls, default image ] - const urls = props.urls || []; - if (props.url) { - urls.unshift(props.url); // put in urls[0] + let urls = []; + if (!SettingsStore.getValue("lowBandwidth")) { + urls = props.urls || []; + + if (props.url) { + urls.unshift(props.url); // put in urls[0] + } } let defaultImageUrl = null; @@ -116,6 +121,10 @@ module.exports = React.createClass({ ); urls.push(defaultImageUrl); // lowest priority } + + // deduplicate URLs + urls = Array.from(new Set(urls)); + return { imageUrls: urls, defaultImageUrl: defaultImageUrl, diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 2e4611f7d0..04bc7c75ef 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -29,6 +29,10 @@ import SettingsStore from '../../../settings/SettingsStore'; import { isUrlPermitted } from '../../../HtmlUtils'; import { isContentActionable } from '../../../utils/EventUtils'; +function canCancel(eventStatus) { + return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; +} + module.exports = React.createClass({ displayName: 'MessageContextMenu', @@ -90,6 +94,23 @@ module.exports = React.createClass({ this.closeMenu(); }, + onResendEditClick: function() { + Resend.resend(this.props.mxEvent.replacingEvent()); + this.closeMenu(); + }, + + onResendRedactionClick: function() { + Resend.resend(this.props.mxEvent.localRedactionEvent()); + this.closeMenu(); + }, + + onResendReactionsClick: function() { + for (const reaction of this._getUnsentReactions()) { + Resend.resend(reaction); + } + this.closeMenu(); + }, + e2eInfoClicked: function() { this.props.e2eInfoCallback(); this.closeMenu(); @@ -119,26 +140,54 @@ module.exports = React.createClass({ onRedactClick: function() { const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { - onFinished: (proceed) => { + onFinished: async (proceed) => { if (!proceed) return; const cli = MatrixClientPeg.get(); - cli.redactEvent(this.props.mxEvent.getRoomId(), this.props.mxEvent.getId()).catch(function(e) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - // display error message stating you couldn't delete this. + try { + await cli.redactEvent( + this.props.mxEvent.getRoomId(), + this.props.mxEvent.getId(), + ); + } catch (e) { const code = e.errcode || e.statusCode; - Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, { - title: _t('Error'), - description: _t('You cannot delete this message. (%(code)s)', {code}), - }); - }).done(); + // only show the dialog if failing for something other than a network error + // (e.g. no errcode or statusCode) as in that case the redactions end up in the + // detached queue and we show the room status bar to allow retry + if (typeof code !== "undefined") { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + // display error message stating you couldn't delete this. + Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, { + title: _t('Error'), + description: _t('You cannot delete this message. (%(code)s)', {code}), + }); + } + } }, }, 'mx_Dialog_confirmredact'); this.closeMenu(); }, onCancelSendClick: function() { - Resend.removeFromQueue(this.props.mxEvent); + const mxEvent = this.props.mxEvent; + const editEvent = mxEvent.replacingEvent(); + const redactEvent = mxEvent.localRedactionEvent(); + const pendingReactions = this._getPendingReactions(); + + if (editEvent && canCancel(editEvent.status)) { + Resend.removeFromQueue(editEvent); + } + if (redactEvent && canCancel(redactEvent.status)) { + Resend.removeFromQueue(redactEvent); + } + if (pendingReactions.length) { + for (const reaction of pendingReactions) { + Resend.removeFromQueue(reaction); + } + } + if (canCancel(mxEvent.status)) { + Resend.removeFromQueue(this.props.mxEvent); + } this.closeMenu(); }, @@ -207,10 +256,42 @@ module.exports = React.createClass({ this.closeMenu(); }, + _getReactions(filter) { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(this.props.mxEvent.getRoomId()); + const eventId = this.props.mxEvent.getId(); + return room.getPendingEvents().filter(e => { + const relation = e.getRelation(); + return relation && + relation.rel_type === "m.annotation" && + relation.event_id === eventId && + filter(e); + }); + }, + + _getPendingReactions() { + return this._getReactions(e => canCancel(e.status)); + }, + + _getUnsentReactions() { + return this._getReactions(e => e.status === EventStatus.NOT_SENT); + }, + render: function() { const mxEvent = this.props.mxEvent; const eventStatus = mxEvent.status; + const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status; + const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status; + const unsentReactionsCount = this._getUnsentReactions().length; + const pendingReactionsCount = this._getPendingReactions().length; + const allowCancel = canCancel(mxEvent.status) || + canCancel(editStatus) || + canCancel(redactStatus) || + pendingReactionsCount !== 0; let resendButton; + let resendEditButton; + let resendReactionsButton; + let resendRedactionButton; let redactButton; let cancelButton; let forwardButton; @@ -223,11 +304,36 @@ module.exports = React.createClass({ // status is SENT before remote-echo, null after const isSent = !eventStatus || eventStatus === EventStatus.SENT; + if (!mxEvent.isRedacted()) { + if (eventStatus === EventStatus.NOT_SENT) { + resendButton = ( +
    + { _t('Resend') } +
    + ); + } - if (eventStatus === EventStatus.NOT_SENT) { - resendButton = ( -
    - { _t('Resend') } + if (editStatus === EventStatus.NOT_SENT) { + resendEditButton = ( +
    + { _t('Resend edit') } +
    + ); + } + + if (unsentReactionsCount !== 0) { + resendReactionsButton = ( +
    + { _t('Resend %(unsentCount)s reaction(s)', {unsentCount: unsentReactionsCount}) } +
    + ); + } + } + + if (redactStatus === EventStatus.NOT_SENT) { + resendRedactionButton = ( +
    + { _t('Resend removal') }
    ); } @@ -240,7 +346,7 @@ module.exports = React.createClass({ ); } - if (eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT) { + if (allowCancel) { cancelButton = (
    { _t('Cancel Sending') } @@ -342,6 +448,9 @@ module.exports = React.createClass({ return (
    { resendButton } + { resendEditButton } + { resendReactionsButton } + { resendRedactionButton } { redactButton } { cancelButton } { forwardButton } diff --git a/src/components/views/context_menus/RoomTileContextMenu.js b/src/components/views/context_menus/RoomTileContextMenu.js index f494fbd961..f3a36b6ced 100644 --- a/src/components/views/context_menus/RoomTileContextMenu.js +++ b/src/components/views/context_menus/RoomTileContextMenu.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations 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. @@ -15,8 +16,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import Promise from 'bluebird'; import React from 'react'; import classNames from 'classnames'; @@ -30,6 +29,7 @@ import * as Rooms from '../../../Rooms'; import * as RoomNotifs from '../../../RoomNotifs'; import Modal from '../../../Modal'; import RoomListActions from '../../../actions/RoomListActions'; +import RoomViewStore from '../../../stores/RoomViewStore'; module.exports = React.createClass({ displayName: 'RoomTileContextMenu', @@ -158,8 +158,12 @@ module.exports = React.createClass({ _onClickForget: function() { // FIXME: duplicated with RoomSettings (and dead code in RoomView) - MatrixClientPeg.get().forget(this.props.room.roomId).done(function() { - dis.dispatch({ action: 'view_next_room' }); + MatrixClientPeg.get().forget(this.props.room.roomId).done(() => { + // Switch to another room view if we're currently viewing the + // historical room + if (RoomViewStore.getRoomId() === this.props.room.roomId) { + dis.dispatch({ action: 'view_next_room' }); + } }, function(err) { const errCode = err.errcode || _td("unknown error code"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -369,25 +373,27 @@ module.exports = React.createClass({ render: function() { const myMembership = this.props.room.getMyMembership(); - // Can't set notif level or tags on non-join rooms - if (myMembership !== 'join') { - return
    - { this._renderLeaveMenu(myMembership) } -
    - { this._renderSettingsMenu() } -
    ; + switch (myMembership) { + case 'join': + return
    + { this._renderNotifMenu() } +
    + { this._renderLeaveMenu(myMembership) } +
    + { this._renderRoomTagMenu() } +
    + { this._renderSettingsMenu() } +
    ; + case 'invite': + return
    + { this._renderLeaveMenu(myMembership) } +
    ; + default: + return
    + { this._renderLeaveMenu(myMembership) } +
    + { this._renderSettingsMenu() } +
    ; } - - return ( -
    - { this._renderNotifMenu() } -
    - { this._renderLeaveMenu(myMembership) } -
    - { this._renderRoomTagMenu() } -
    - { this._renderSettingsMenu() } -
    - ); }, }); diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 67fd197f8a..ac2181f1f2 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -1,6 +1,8 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018, 2019 New Vector Ltd +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +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. @@ -17,13 +19,16 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; + import { _t, _td } from '../../../languageHandler'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import Promise from 'bluebird'; import { addressTypes, getAddressType } from '../../../UserAddress.js'; import GroupStore from '../../../stores/GroupStore'; -import * as Email from "../../../email"; +import * as Email from '../../../email'; +import IdentityAuthClient from '../../../IdentityAuthClient'; const TRUNCATE_QUERY_LIST = 40; const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200; @@ -35,7 +40,7 @@ const addressTypeName = { }; -module.exports = React.createClass({ +module.exports = createReactClass({ displayName: "AddressPickerDialog", propTypes: { @@ -70,12 +75,11 @@ module.exports = React.createClass({ getInitialState: function() { return { - error: false, - + // Whether to show an error message because of an invalid address + invalidAddressError: false, // List of UserAddressType objects representing // the list of addresses we're going to invite selectedList: [], - // Whether a search is ongoing busy: false, // An error message generated during the user directory search @@ -102,7 +106,7 @@ module.exports = React.createClass({ // Check the text input field to see if user has an unconverted address // If there is and it's valid add it to the local selectedList if (this.refs.textinput.value !== '') { - selectedList = this._addInputToList(); + selectedList = this._addAddressesToList([this.refs.textinput.value]); if (selectedList === null) return; } this.props.onFinished(true, selectedList); @@ -140,12 +144,12 @@ module.exports = React.createClass({ // if there's nothing in the input box, submit the form this.onButtonClick(); } else { - this._addInputToList(); + this._addAddressesToList([this.refs.textinput.value]); } } else if (e.keyCode === 188 || e.keyCode === 9) { // comma or tab e.stopPropagation(); e.preventDefault(); - this._addInputToList(); + this._addAddressesToList([this.refs.textinput.value]); } }, @@ -205,7 +209,7 @@ module.exports = React.createClass({ onSelected: function(index) { const selectedList = this.state.selectedList.slice(); - selectedList.push(this.state.suggestedList[index]); + selectedList.push(this._getFilteredSuggestions()[index]); this.setState({ selectedList, suggestedList: [], @@ -442,56 +446,62 @@ module.exports = React.createClass({ }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); if (addrType === 'email') { - this._lookupThreepid(addrType, query).done(); + this._lookupThreepid(addrType, query); } } this.setState({ suggestedList, - error: false, + invalidAddressError: false, }, () => { if (this.addressSelector) this.addressSelector.moveSelectionTop(); }); }, - _addInputToList: function() { - const addressText = this.refs.textinput.value.trim(); - const addrType = getAddressType(addressText); - const addrObj = { - addressType: addrType, - address: addressText, - isKnown: false, - }; - if (!this.props.validAddressTypes.includes(addrType)) { - this.setState({ error: true }); - return null; - } else if (addrType === 'mx-user-id') { - const user = MatrixClientPeg.get().getUser(addrObj.address); - if (user) { - addrObj.displayName = user.displayName; - addrObj.avatarMxc = user.avatarUrl; - addrObj.isKnown = true; - } - } else if (addrType === 'mx-room-id') { - const room = MatrixClientPeg.get().getRoom(addrObj.address); - if (room) { - addrObj.displayName = room.name; - addrObj.avatarMxc = room.avatarUrl; - addrObj.isKnown = true; - } - } - + _addAddressesToList: function(addressTexts) { const selectedList = this.state.selectedList.slice(); - selectedList.push(addrObj); + + let hasError = false; + addressTexts.forEach((addressText) => { + addressText = addressText.trim(); + const addrType = getAddressType(addressText); + const addrObj = { + addressType: addrType, + address: addressText, + isKnown: false, + }; + + if (!this.props.validAddressTypes.includes(addrType)) { + hasError = true; + } else if (addrType === 'mx-user-id') { + const user = MatrixClientPeg.get().getUser(addrObj.address); + if (user) { + addrObj.displayName = user.displayName; + addrObj.avatarMxc = user.avatarUrl; + addrObj.isKnown = true; + } + } else if (addrType === 'mx-room-id') { + const room = MatrixClientPeg.get().getRoom(addrObj.address); + if (room) { + addrObj.displayName = room.name; + addrObj.avatarMxc = room.avatarUrl; + addrObj.isKnown = true; + } + } + + selectedList.push(addrObj); + }); + this.setState({ selectedList, suggestedList: [], query: "", + invalidAddressError: hasError ? true : this.state.invalidAddressError, }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); - return selectedList; + return hasError ? null : selectedList; }, - _lookupThreepid: function(medium, address) { + _lookupThreepid: async function(medium, address) { let cancelled = false; // Note that we can't safely remove this after we're done // because we don't know that it's the same one, so we just @@ -502,36 +512,44 @@ module.exports = React.createClass({ }; // wait a bit to let the user finish typing - return Promise.delay(500).then(() => { - if (cancelled) return null; - return MatrixClientPeg.get().lookupThreePid(medium, address); - }).then((res) => { - if (res === null || !res.mxid) return null; + await Promise.delay(500); + if (cancelled) return null; + + try { + const authClient = new IdentityAuthClient(); + const identityAccessToken = await authClient.getAccessToken(); if (cancelled) return null; - return MatrixClientPeg.get().getProfileInfo(res.mxid); - }).then((res) => { - if (res === null) return null; - if (cancelled) return null; + const lookup = await MatrixClientPeg.get().lookupThreePid( + medium, + address, + undefined /* callback */, + identityAccessToken, + ); + if (cancelled || lookup === null || !lookup.mxid) return null; + + const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid); + if (cancelled || profile === null) return null; + this.setState({ suggestedList: [{ // a UserAddressType addressType: medium, address: address, - displayName: res.displayname, - avatarMxc: res.avatar_url, + displayName: profile.displayname, + avatarMxc: profile.avatar_url, isKnown: true, }], }); - }); + } catch (e) { + console.error(e); + this.setState({ + searchError: _t('Something went wrong!'), + }); + } }, - render: function() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const AddressSelector = sdk.getComponent("elements.AddressSelector"); - this.scrollElement = null; - + _getFilteredSuggestions: function() { // map addressType => set of addresses to avoid O(n*m) operation const selectedAddresses = {}; this.state.selectedList.forEach(({address, addressType}) => { @@ -540,9 +558,24 @@ module.exports = React.createClass({ }); // Filter out any addresses in the above already selected addresses (matching both type and address) - const filteredSuggestedList = this.state.suggestedList.filter(({address, addressType}) => { + return this.state.suggestedList.filter(({address, addressType}) => { return !(selectedAddresses[addressType] && selectedAddresses[addressType].has(address)); }); + }, + + _onPaste: function(e) { + // Prevent the text being pasted into the textarea + e.preventDefault(); + const text = e.clipboardData.getData("text"); + // Process it as a list of addresses to add instead + this._addAddressesToList(text.split(/[\s,]+/)); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const AddressSelector = sdk.getComponent("elements.AddressSelector"); + this.scrollElement = null; const query = []; // create the invite list @@ -562,7 +595,9 @@ module.exports = React.createClass({ // Add the query at the end query.push( -