diff --git a/.babelrc b/.babelrc index fc5bd1788f..3fb847ad18 100644 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,21 @@ { - "presets": ["react", "es2015", "es2016"], - "plugins": ["transform-class-properties", "transform-object-rest-spread", "transform-async-to-bluebird", "transform-runtime", "add-module-exports", "syntax-dynamic-import"] + "presets": [ + "react", + "es2015", + "es2016" + ], + "plugins": [ + [ + "transform-builtin-extend", + { + "globals": ["Error"] + } + ], + "transform-class-properties", + "transform-object-rest-spread", + "transform-async-to-bluebird", + "transform-runtime", + "add-module-exports", + "syntax-dynamic-import" + ] } diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml index 04b047436b..946e417676 100644 --- a/.buildkite/pipeline.yaml +++ b/.buildkite/pipeline.yaml @@ -3,25 +3,37 @@ steps: command: - "yarn install" - "yarn lintwithexclusions" + - "yarn stylelint" plugins: - docker#v3.0.1: image: "node:10" -# - label: ":chains: End-to-End Tests" -# command: -# # TODO: Remove hacky chmod for BuildKite -# - "chmod +x ./scripts/ci/*.sh" -# - "chmod +x ./scripts/*" -# - "sudo apt-get install build-essential python2.7-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev" -# - "./scripts/ci/install-deps.sh" -# - "./scripts/ci/end-to-end-tests.sh" -# plugins: -# - docker#v3.0.1: -# image: "node:10" + - label: ":chains: End-to-End Tests" + agents: + # We use a medium sized instance instead of the normal small ones because + # e2e tests otherwise take +-8min + queue: "medium" + command: + # 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" + - "./scripts/ci/end-to-end-tests.sh" + plugins: + - docker#v3.0.1: + image: "matrixdotorg/riotweb-ci-e2etests-env:latest" + propagate-environment: true - label: ":karma: Tests" + agents: + # We use a medium sized instance instead of the normal small ones because + # webpack loves to gorge itself on resources. + queue: "medium" command: # Install chrome + - "echo '--- Installing Chrome'" - "wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -" - "sh -c 'echo \"deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main\" >> /etc/apt/sources.list.d/google.list'" - "apt-get update" @@ -30,7 +42,9 @@ steps: # TODO: Remove hacky chmod for BuildKite - "chmod +x ./scripts/ci/*.sh" - "chmod +x ./scripts/*" + - "echo '--- Installing Dependencies'" - "./scripts/ci/install-deps.sh" + - "echo '+++ Running Tests'" - "./scripts/ci/unit-tests.sh" env: CHROME_BIN: "/usr/bin/google-chrome-stable" @@ -40,8 +54,13 @@ steps: propagate-environment: true - label: "🔧 Riot Tests" + agents: + # We use a medium sized instance instead of the normal small ones because + # webpack loves to gorge itself on resources. + queue: "medium" command: # Install chrome + - "echo '--- Installing Chrome'" - "wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -" - "sh -c 'echo \"deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main\" >> /etc/apt/sources.list.d/google.list'" - "apt-get update" @@ -50,7 +69,9 @@ steps: # TODO: Remove hacky chmod for BuildKite - "chmod +x ./scripts/ci/*.sh" - "chmod +x ./scripts/*" + - "echo '--- Installing Dependencies'" - "./scripts/ci/install-deps.sh" + - "echo '+++ Running Tests'" - "./scripts/ci/riot-unit-tests.sh" env: CHROME_BIN: "/usr/bin/google-chrome-stable" @@ -58,3 +79,13 @@ steps: - docker#v3.0.1: image: "node:10" propagate-environment: true + + - wait + + - label: "🐴 Trigger riot-web" + trigger: "riot-web" + branches: "develop" + build: + branch: "develop" + message: "[react-sdk] ${BUILDKITE_MESSAGE}" + async: true diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index c7d5804d66..ffe0ade5cc 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -10,7 +10,6 @@ src/components/structures/RoomStatusBar.js src/components/structures/RoomView.js src/components/structures/ScrollPanel.js src/components/structures/SearchBox.js -src/components/structures/TimelinePanel.js src/components/structures/UploadBar.js src/components/views/avatars/BaseAvatar.js src/components/views/avatars/MemberAvatar.js @@ -52,7 +51,6 @@ 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/ContentMessages.js src/GroupAddressPicker.js src/HtmlUtils.js src/ImageUtils.js @@ -60,7 +58,6 @@ src/languageHandler.js src/linkify-matrix.js src/Markdown.js src/MatrixClientPeg.js -src/Modal.js src/notifications/ContentRules.js src/notifications/PushRuleVectorState.js src/notifications/VectorPushRulesDefinitions.js diff --git a/.gitignore b/.gitignore index 7a3b1061b0..33e8bfc7ac 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,6 @@ package-lock.json /git-revision.txt /matrix-react-sdk-*.tgz -# test reports created by karma -/karma-reports - /.idea /src/component-index.js diff --git a/.stylelintrc.js b/.stylelintrc.js new file mode 100644 index 0000000000..fc00b643a0 --- /dev/null +++ b/.stylelintrc.js @@ -0,0 +1,15 @@ +module.exports = { + "extends": "stylelint-config-standard", + "rules": { + "indentation": 4, + "comment-empty-line-before": null, + "declaration-empty-line-before": null, + "length-zero-no-unit": null, + "rule-empty-line-before": null, + "color-hex-length": null, + "max-empty-lines": null, + "number-no-trailing-zeros": null, + "number-leading-zero": null, + "selector-list-comma-newline-after": null, + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 76852d6575..4d9a01e668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,313 @@ +Changes in [1.1.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.1.0) (2019-05-07) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.1.0-rc.1...v1.1.0) + + * Relax password requirements to score of 3 out of 4 + [\#2949](https://github.com/matrix-org/matrix-react-sdk/pull/2949) + * Restore access to message quote option on first click + [\#2948](https://github.com/matrix-org/matrix-react-sdk/pull/2948) + * Check for `room` in all `Room.timeline*` handlers + [\#2946](https://github.com/matrix-org/matrix-react-sdk/pull/2946) + +Changes in [1.1.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.1.0-rc.1) (2019-04-30) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.0.7...v1.1.0-rc.1) + + * Add important info to new preview bar + [\#2936](https://github.com/matrix-org/matrix-react-sdk/pull/2936) + * Add a message action bar + [\#2935](https://github.com/matrix-org/matrix-react-sdk/pull/2935) + * Trigger riot-web build + [\#2934](https://github.com/matrix-org/matrix-react-sdk/pull/2934) + * Input validation tooltips for registration + [\#2933](https://github.com/matrix-org/matrix-react-sdk/pull/2933) + * Also say "Connect ..." on remaining key backup buttons + [\#2931](https://github.com/matrix-org/matrix-react-sdk/pull/2931) + * Mark a few CSS classes as not selectable + [\#2929](https://github.com/matrix-org/matrix-react-sdk/pull/2929) + * Cleanup message composer render() method + [\#2883](https://github.com/matrix-org/matrix-react-sdk/pull/2883) + * Redesigned room preview bar + [\#2925](https://github.com/matrix-org/matrix-react-sdk/pull/2925) + * Prevent user pills containing only emoji from embiggening + [\#2907](https://github.com/matrix-org/matrix-react-sdk/pull/2907) + * Make alt-enter insert new line on macOS + [\#2923](https://github.com/matrix-org/matrix-react-sdk/pull/2923) + * Test `defaultServerName` before showing it on forgot password + [\#2924](https://github.com/matrix-org/matrix-react-sdk/pull/2924) + * Add a function to append/overwrite objects in the config on the fly + [\#2922](https://github.com/matrix-org/matrix-react-sdk/pull/2922) + * use SdkConfig brand name instead of static "Riot" + [\#2921](https://github.com/matrix-org/matrix-react-sdk/pull/2921) + * Use dedicated permalink creators in search results with multiple rooms + [\#2898](https://github.com/matrix-org/matrix-react-sdk/pull/2898) + * Clarify that use backup means restore + [\#2917](https://github.com/matrix-org/matrix-react-sdk/pull/2917) + * Fix key backup status when missing device + [\#2919](https://github.com/matrix-org/matrix-react-sdk/pull/2919) + * Ensure `` tags appear bold for all browsers + [\#2918](https://github.com/matrix-org/matrix-react-sdk/pull/2918) + * Add a link in room settings to get at the tombstoned room if it exists + [\#2908](https://github.com/matrix-org/matrix-react-sdk/pull/2908) + * Add a generic error page element for startup errors + [\#2915](https://github.com/matrix-org/matrix-react-sdk/pull/2915) + * Add strings for js-sdk autodiscovery errors + [\#2916](https://github.com/matrix-org/matrix-react-sdk/pull/2916) + * Focus the composer view on file upload + [\#2914](https://github.com/matrix-org/matrix-react-sdk/pull/2914) + * use medium agent for e2e tests + [\#2911](https://github.com/matrix-org/matrix-react-sdk/pull/2911) + * adjust prop in HeaderButton + [\#2912](https://github.com/matrix-org/matrix-react-sdk/pull/2912) + * Remove breadcrumb scroll tolerances and use sensible defaults + [\#2913](https://github.com/matrix-org/matrix-react-sdk/pull/2913) + * Fix having to click the member list button twice to show it after having + changed room. + [\#2906](https://github.com/matrix-org/matrix-react-sdk/pull/2906) + * Add period to the end of upgrade notice + [\#2909](https://github.com/matrix-org/matrix-react-sdk/pull/2909) + * Remove duplicate space in credits + [\#2889](https://github.com/matrix-org/matrix-react-sdk/pull/2889) + * Handle M_UNSUPPORTED_ROOM_VERSION in invites and room creation + [\#2905](https://github.com/matrix-org/matrix-react-sdk/pull/2905) + * Re-enable E2E tests + [\#2867](https://github.com/matrix-org/matrix-react-sdk/pull/2867) + * Remove BottomLeftMenu and supporting bits + [\#2903](https://github.com/matrix-org/matrix-react-sdk/pull/2903) + * Fix for retina thumbnails being massive + [\#2439](https://github.com/matrix-org/matrix-react-sdk/pull/2439) + * Send breadcrumb updates only when they change + [\#2894](https://github.com/matrix-org/matrix-react-sdk/pull/2894) + * Add some tolerances to breadcrumb scrolling + [\#2892](https://github.com/matrix-org/matrix-react-sdk/pull/2892) + * Fix validation to avoid `undefined` class on fields + [\#2902](https://github.com/matrix-org/matrix-react-sdk/pull/2902) + * Always return a client from onRegistered + [\#2895](https://github.com/matrix-org/matrix-react-sdk/pull/2895) + * Fix room upgrade warnings popping up in upgraded rooms + [\#2897](https://github.com/matrix-org/matrix-react-sdk/pull/2897) + * Fix style lint errors & enable on CI + [\#2901](https://github.com/matrix-org/matrix-react-sdk/pull/2901) + * Add stylelint + [\#2900](https://github.com/matrix-org/matrix-react-sdk/pull/2900) + * Key backup: Handle case where your onw sig is invalid + [\#2899](https://github.com/matrix-org/matrix-react-sdk/pull/2899) + * Simplify settings dialog CSS + [\#2891](https://github.com/matrix-org/matrix-react-sdk/pull/2891) + * Fix upload cancel in e2e rooms + [\#2893](https://github.com/matrix-org/matrix-react-sdk/pull/2893) + * Set E2E room status to warning when crypto is disabled + [\#2890](https://github.com/matrix-org/matrix-react-sdk/pull/2890) + * Move SettingsDialog width override to fixedWidth + [\#2888](https://github.com/matrix-org/matrix-react-sdk/pull/2888) + * Prevent the permalink creator from causing cascading failure + [\#2882](https://github.com/matrix-org/matrix-react-sdk/pull/2882) + * Don't include all networks by default in the room directory + [\#2881](https://github.com/matrix-org/matrix-react-sdk/pull/2881) + * Fix fixed width dialogs + [\#2886](https://github.com/matrix-org/matrix-react-sdk/pull/2886) + * Fix settings dialog layout + [\#2885](https://github.com/matrix-org/matrix-react-sdk/pull/2885) + * Update from Weblate + [\#2884](https://github.com/matrix-org/matrix-react-sdk/pull/2884) + * Design tweaks to dialogs + [\#2868](https://github.com/matrix-org/matrix-react-sdk/pull/2868) + * Remove 'try the app' link from login + [\#2880](https://github.com/matrix-org/matrix-react-sdk/pull/2880) + * Track store failures after startup + [\#2870](https://github.com/matrix-org/matrix-react-sdk/pull/2870) + * Translate vertical scrolling to horizontal movement in breadcrumbs + [\#2877](https://github.com/matrix-org/matrix-react-sdk/pull/2877) + * Add telemetry for breadcrumbs and have the setting apply without refresh + [\#2873](https://github.com/matrix-org/matrix-react-sdk/pull/2873) + * Fix a few bugs introduced in file upload rework + [\#2879](https://github.com/matrix-org/matrix-react-sdk/pull/2879) + * Sync breadcrumb rooms through account data + [\#2875](https://github.com/matrix-org/matrix-react-sdk/pull/2875) + * Scroll breadcrumbs to the left when they change + [\#2878](https://github.com/matrix-org/matrix-react-sdk/pull/2878) + * Add an indicator to show a room is a direct chat in breadcrumbs + [\#2874](https://github.com/matrix-org/matrix-react-sdk/pull/2874) + * Use the most recent version of the room in breadcrumbs + [\#2872](https://github.com/matrix-org/matrix-react-sdk/pull/2872) + * Autohide the scrollbar on breadcrumbs + [\#2876](https://github.com/matrix-org/matrix-react-sdk/pull/2876) + * Ensure the page URL is redacted before tracking analytics events + [\#2871](https://github.com/matrix-org/matrix-react-sdk/pull/2871) + * fix NPE for rooms with redacted tombstones + [\#2869](https://github.com/matrix-org/matrix-react-sdk/pull/2869) + * Don't re-init the stickerpicker unless something actually changes + [\#2862](https://github.com/matrix-org/matrix-react-sdk/pull/2862) + * Add option to rotate images + [\#2855](https://github.com/matrix-org/matrix-react-sdk/pull/2855) + * Add badges to breadcrumb rooms + [\#2861](https://github.com/matrix-org/matrix-react-sdk/pull/2861) + * Include the current power level in the selector + [\#2866](https://github.com/matrix-org/matrix-react-sdk/pull/2866) + * Apply 50% opacity to left breadcrumbs + [\#2860](https://github.com/matrix-org/matrix-react-sdk/pull/2860) + * Small scroll fixes + [\#2865](https://github.com/matrix-org/matrix-react-sdk/pull/2865) + * Put the stickerpicker below dialogs + [\#2863](https://github.com/matrix-org/matrix-react-sdk/pull/2863) + * Logging tweaks + [\#2864](https://github.com/matrix-org/matrix-react-sdk/pull/2864) + * Implement redesigned upload confirmation screens + [\#2858](https://github.com/matrix-org/matrix-react-sdk/pull/2858) + * Use Field component in bug report dialog + [\#2859](https://github.com/matrix-org/matrix-react-sdk/pull/2859) + * Notify user when crypto data is missing + [\#2841](https://github.com/matrix-org/matrix-react-sdk/pull/2841) + * Update from Weblate + [\#2857](https://github.com/matrix-org/matrix-react-sdk/pull/2857) + * Download PDFs as blobs to avoid empty grey screens + [\#2847](https://github.com/matrix-org/matrix-react-sdk/pull/2847) + * Set title attribute on images in lightbox + [\#2848](https://github.com/matrix-org/matrix-react-sdk/pull/2848) + * Add MemberInfo for 3pid invites and support revoking those invites + [\#2843](https://github.com/matrix-org/matrix-react-sdk/pull/2843) + * round scrollTop upwards to prevent never detecting bottom + [\#2846](https://github.com/matrix-org/matrix-react-sdk/pull/2846) + * Notifier is how singleton is known outside of this module + [\#2845](https://github.com/matrix-org/matrix-react-sdk/pull/2845) + * Delay `Notifier` check until we have push rules + [\#2844](https://github.com/matrix-org/matrix-react-sdk/pull/2844) + * BACAT Scrolling + [\#2842](https://github.com/matrix-org/matrix-react-sdk/pull/2842) + * Handle storage fallback cases in consistency check + [\#2840](https://github.com/matrix-org/matrix-react-sdk/pull/2840) + * Handle all the segments of a v3 event ID + [\#2827](https://github.com/matrix-org/matrix-react-sdk/pull/2827) + * Add custom tooltips and scrolling to breadcrumbs + [\#2839](https://github.com/matrix-org/matrix-react-sdk/pull/2839) + * Check if the message panel is at the end of the timeline on init + [\#2829](https://github.com/matrix-org/matrix-react-sdk/pull/2829) + * Persist breadcrumb state between sessions + [\#2837](https://github.com/matrix-org/matrix-react-sdk/pull/2837) + * Always append the current room to the breadcrumbs + [\#2838](https://github.com/matrix-org/matrix-react-sdk/pull/2838) + * Alert the user to unread notifications in prior versions of rooms + [\#2831](https://github.com/matrix-org/matrix-react-sdk/pull/2831) + * Filter out upgraded rooms from autocomplete results + [\#2830](https://github.com/matrix-org/matrix-react-sdk/pull/2830) + +Changes in [1.0.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.0.7) (2019-04-08) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.0.6...v1.0.7) + + * Hotfix: bump js-sdk to 1.0.4, see https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.0.4 + +Changes in [1.0.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.0.6) (2019-04-01) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.0.6-rc.1...v1.0.6) + + * Handle storage fallback cases in consistency check + [\#2853](https://github.com/matrix-org/matrix-react-sdk/pull/2853) + * Set title attribute on images in lightbox + [\#2852](https://github.com/matrix-org/matrix-react-sdk/pull/2852) + * Download PDFs as blobs to avoid empty grey screens + [\#2851](https://github.com/matrix-org/matrix-react-sdk/pull/2851) + * Add MemberInfo for 3pid invites and support revoking those invites + [\#2850](https://github.com/matrix-org/matrix-react-sdk/pull/2850) + +Changes in [1.0.6-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.0.6-rc.1) (2019-03-27) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.0.5...v1.0.6-rc.1) + + * Catch errors when checking IndexedDB + [\#2836](https://github.com/matrix-org/matrix-react-sdk/pull/2836) + * Remove noreferrer on widget pop-out + [\#2835](https://github.com/matrix-org/matrix-react-sdk/pull/2835) + * Rework room directory so that new room is always available + [\#2834](https://github.com/matrix-org/matrix-react-sdk/pull/2834) + * Send telemetry about storage consistency + [\#2832](https://github.com/matrix-org/matrix-react-sdk/pull/2832) + * Widget OpenID reauth implementation + [\#2781](https://github.com/matrix-org/matrix-react-sdk/pull/2781) + * Log results of basic storage consistency check + [\#2826](https://github.com/matrix-org/matrix-react-sdk/pull/2826) + * Clarify devices affected by notification settings + [\#2828](https://github.com/matrix-org/matrix-react-sdk/pull/2828) + * Add a command for creating custom widgets without an integration manager + [\#2824](https://github.com/matrix-org/matrix-react-sdk/pull/2824) + * Minimize stickerpicker when the title is clicked + [\#2822](https://github.com/matrix-org/matrix-react-sdk/pull/2822) + * Add blocks around homeserver and identity server urls + [\#2825](https://github.com/matrix-org/matrix-react-sdk/pull/2825) + * Fixed drop shadow for tooltip. + [\#2815](https://github.com/matrix-org/matrix-react-sdk/pull/2815) + * Ask the user for debug logs when the timeline explodes + [\#2820](https://github.com/matrix-org/matrix-react-sdk/pull/2820) + * Fix typo preventing users from adding more widgets easily + [\#2823](https://github.com/matrix-org/matrix-react-sdk/pull/2823) + * Attach an onChange listener to the room's blacklist devices option + [\#2817](https://github.com/matrix-org/matrix-react-sdk/pull/2817) + * Use leaveRoomChain when leaving a room + [\#2818](https://github.com/matrix-org/matrix-react-sdk/pull/2818) + * Fix bug with NetworkList dropdown + [\#2821](https://github.com/matrix-org/matrix-react-sdk/pull/2821) + * Trim the logging for URL previews + [\#2816](https://github.com/matrix-org/matrix-react-sdk/pull/2816) + * Explicitly create `cryptoStore` in React SDK + [\#2814](https://github.com/matrix-org/matrix-react-sdk/pull/2814) + * Change to new consistent name for `MemoryStore` + [\#2812](https://github.com/matrix-org/matrix-react-sdk/pull/2812) + * Use medium agents for the more resource intensive builds + [\#2813](https://github.com/matrix-org/matrix-react-sdk/pull/2813) + * Add log grouping to buildkite + [\#2810](https://github.com/matrix-org/matrix-react-sdk/pull/2810) + * Switch to `git` protocol for CI dependencies + [\#2809](https://github.com/matrix-org/matrix-react-sdk/pull/2809) + * Go back to using mainine velocity + [\#2808](https://github.com/matrix-org/matrix-react-sdk/pull/2808) + * Warn that members won't be autojoined to upgraded rooms + [\#2796](https://github.com/matrix-org/matrix-react-sdk/pull/2796) + * Support CI for matching branches on forks + [\#2807](https://github.com/matrix-org/matrix-react-sdk/pull/2807) + * Discard old sticker picker when the URL changes + [\#2801](https://github.com/matrix-org/matrix-react-sdk/pull/2801) + * Reload widget messaging when widgets reload + [\#2799](https://github.com/matrix-org/matrix-react-sdk/pull/2799) + * Don't show calculated room name in room settings name input field + [\#2806](https://github.com/matrix-org/matrix-react-sdk/pull/2806) + * Disable big emoji for m.emote messages as it looks weird + [\#2805](https://github.com/matrix-org/matrix-react-sdk/pull/2805) + * Remove Edge from browser support statements + [\#2803](https://github.com/matrix-org/matrix-react-sdk/pull/2803) + * Update from Weblate + [\#2802](https://github.com/matrix-org/matrix-react-sdk/pull/2802) + * Really fix tag panel + [\#2800](https://github.com/matrix-org/matrix-react-sdk/pull/2800) + * Update CompatibilityPage to match officially supported browsers + [\#2793](https://github.com/matrix-org/matrix-react-sdk/pull/2793) + * Use Buildkite for CI + [\#2788](https://github.com/matrix-org/matrix-react-sdk/pull/2788) + * Fix CSS syntax errors preventing offline member opacity from working + [\#2794](https://github.com/matrix-org/matrix-react-sdk/pull/2794) + * Make the EntityTile chevron a masked SVG for theming + [\#2795](https://github.com/matrix-org/matrix-react-sdk/pull/2795) + * Remove refs from `RegistrationForm` + [\#2791](https://github.com/matrix-org/matrix-react-sdk/pull/2791) + * Fix initial letter avatar vertical offset in Firefox + [\#2792](https://github.com/matrix-org/matrix-react-sdk/pull/2792) + * Fix the custom tag panel + [\#2797](https://github.com/matrix-org/matrix-react-sdk/pull/2797) + * Ensure freshly invited members don't count towards the alone warning + [\#2786](https://github.com/matrix-org/matrix-react-sdk/pull/2786) + * Fix 'forgot password' warning to represent the reality of e2ee + [\#2787](https://github.com/matrix-org/matrix-react-sdk/pull/2787) + * Restore `Field` value getter for `RegistrationForm` + [\#2790](https://github.com/matrix-org/matrix-react-sdk/pull/2790) + * Initial portions of support for Field validation + [\#2780](https://github.com/matrix-org/matrix-react-sdk/pull/2780) + +Changes in [1.0.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.0.5) (2019-03-21) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.0.4...v1.0.5) + + * Hotfix: disable typing notifs jumping prevention for now + [\#2811](https://github.com/matrix-org/matrix-react-sdk/pull/2811) + Changes in [1.0.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.0.4) (2019-03-18) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.0.4-rc.1...v1.0.4) diff --git a/docs/settings.md b/docs/settings.md index c88888663b..1ba8981f84 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -229,7 +229,7 @@ Controllers are notified of changes by the `SettingsStore`, and are given the op ### Features -Features automatically get considered as `disabled` if they are not listed in the `SdkConfig` or `enable_labs` is +Features automatically get considered as `disabled` if they are not listed in the `SdkConfig` or `enableLabs` is false/not set. Features are always checked against the configuration before going through the level order as they have the option of being forced-on or forced-off for the application. This is done by the `features` section and looks something like this: @@ -260,4 +260,4 @@ In practice, handlers which rely on remote changes (account data, room events, e generalized `WatchManager` - a class specifically designed to deduplicate the logic of managing watchers. The handlers which are localized to the local client (device) generally just trigger the `WatchManager` when they manipulate the setting themselves as there's nothing to really 'watch'. - \ No newline at end of file + diff --git a/jenkins.sh b/jenkins.sh index 548373739c..70bc12e42d 100755 --- a/jenkins.sh +++ b/jenkins.sh @@ -29,6 +29,9 @@ yarn lintall -f checkstyle -o eslint.xml || true # re-run the linter, excluding any files known to have errors or warnings. yarn lintwithexclusions +# lint styles +yarn stylelint + # delete the old tarball, if it exists rm -f matrix-react-sdk-*.tgz diff --git a/karma.conf.js b/karma.conf.js index b687be78fa..e2728cdc09 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -94,7 +94,7 @@ module.exports = function (config) { // test results reporter to use // possible values: 'dots', 'progress' // available reporters: https://npmjs.org/browse/keyword/karma-reporter - reporters: ['logcapture', 'spec', 'junit', 'summary'], + reporters: ['logcapture', 'spec', 'summary'], specReporter: { suppressErrorSummary: false, // do print error summary @@ -156,10 +156,6 @@ module.exports = function (config) { // how many browser should be started simultaneous concurrency: Infinity, - junitReporter: { - outputDir: 'karma-reports', - }, - webpack: { module: { rules: [ diff --git a/package.json b/package.json index ad48555fc7..991080cb7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "1.0.4", + "version": "1.1.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -49,6 +49,7 @@ "lint": "eslint src/", "lintall": "eslint src/ test/", "lintwithexclusions": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test", + "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", @@ -79,9 +80,10 @@ "linkifyjs": "^2.1.6", "lodash": "^4.13.1", "lolex": "2.3.2", - "matrix-js-sdk": "1.0.2", + "matrix-js-sdk": "1.1.0", "optimist": "^0.6.1", "pako": "^1.0.5", + "png-chunks-extract": "^1.0.0", "prop-types": "^15.5.8", "qrcode-react": "^0.1.16", "qs": "^6.6.0", @@ -99,7 +101,7 @@ "slate-react": "^0.18.10", "text-encoding-utf-8": "^1.0.1", "url": "^0.11.0", - "velocity-vector": "github:vector-im/velocity#059e3b2", + "velocity-animate": "^1.5.2", "whatwg-fetch": "^1.1.1", "zxcvbn": "^4.4.2" }, @@ -110,6 +112,7 @@ "babel-loader": "^7.1.5", "babel-plugin-add-module-exports": "^0.2.1", "babel-plugin-transform-async-to-bluebird": "^1.1.1", + "babel-plugin-transform-builtin-extend": "^1.1.2", "babel-plugin-transform-class-properties": "^6.24.1", "babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-plugin-transform-runtime": "^6.23.0", @@ -133,14 +136,13 @@ "karma": "^4.0.1", "karma-chrome-launcher": "^2.2.0", "karma-cli": "^1.0.1", - "karma-junit-reporter": "^2.0.0", "karma-logcapture-reporter": "0.0.1", "karma-mocha": "^1.3.0", "karma-sourcemap-loader": "^0.3.7", "karma-spec-reporter": "^0.0.31", "karma-summary-reporter": "^1.5.1", "karma-webpack": "^4.0.0-beta.0", - "matrix-mock-request": "^1.2.1", + "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.1.1", "mocha": "^5.0.5", "react-addons-test-utils": "^15.4.0", @@ -148,6 +150,8 @@ "rimraf": "^2.4.3", "sinon": "^5.0.7", "source-map-loader": "^0.2.3", + "stylelint": "^9.10.1", + "stylelint-config-standard": "^18.2.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 1e388c4531..d3cf1921e0 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -36,6 +36,12 @@ body { color: $warning-color; } +b { + // On Firefox, the default weight for `` is `bolder` which results in no bold + // effect since we only have specific weights of our fonts available. + font-weight: bold; +} + h2 { color: $primary-fg-color; font-weight: 400; @@ -118,7 +124,7 @@ textarea { background-color: transparent; color: $input-darker-fg-color; border-radius: 4px; - border: 1px solid #c1c1c1; + border: 1px solid $dialog-close-fg-color; // these things should probably not be defined // globally margin: 9px; @@ -267,14 +273,18 @@ textarea { font-weight: 300; font-size: 15px; position: relative; - padding: 40px 58px 36px 58px; - width: 60%; - max-width: 704px; - box-shadow: 2px 15px 30px 0 $dialog-shadow-color; + padding: 25px 30px 30px 30px; max-height: 80%; + box-shadow: 2px 15px 30px 0 $dialog-shadow-color; + border-radius: 4px; overflow-y: auto; } +.mx_Dialog_fixedWidth { + width: 60vw; + max-width: 704px; +} + .mx_Dialog_staticWrapper .mx_Dialog { z-index: 4010; } @@ -317,13 +327,13 @@ textarea { .mx_Dialog_header { position: relative; + margin-bottom: 20px; } .mx_Dialog_title { - font-weight: bold; font-size: 22px; line-height: 36px; - color: $primary-fg-color; + color: $dialog-title-fg-color; } .mx_Dialog_header.mx_Dialog_headerWithButton > .mx_Dialog_title { @@ -338,13 +348,14 @@ textarea { mask: url('$(res)/img/feather-customised/cancel.svg'); mask-repeat: no-repeat; mask-position: center; - width: 36px; - height: 36px; - background-color: $primary-fg-color; + mask-size: cover; + width: 14px; + height: 14px; + background-color: $dialog-close-fg-color; cursor: pointer; position: absolute; - top: 20px; - right: 20px; + top: 4px; + right: 0px; } .mx_Dialog_content { @@ -355,6 +366,7 @@ textarea { } .mx_Dialog_buttons { + margin-top: 20px; text-align: right; } @@ -370,6 +382,10 @@ textarea { background-color: $button-secondary-bg-color; } +.mx_Dialog button:last-child { + margin-right: 0px; +} + .mx_Dialog button:hover, .mx_Dialog input[type="submit"]:hover { @mixin mx_DialogButton_hover; } @@ -381,6 +397,7 @@ textarea { .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; } .mx_Dialog button.danger, .mx_Dialog input[type="submit"].danger { @@ -506,3 +523,38 @@ textarea { opacity: 0; cursor: pointer; } + +// username colors +// used by SenderProfile & RoomPreviewBar +.mx_Username_color1 { + color: $username-variant1-color; +} + +.mx_Username_color2 { + color: $username-variant2-color; +} + +.mx_Username_color3 { + color: $username-variant3-color; +} + +.mx_Username_color4 { + color: $username-variant4-color; +} + +.mx_Username_color5 { + color: $username-variant5-color; +} + +.mx_Username_color6 { + color: $username-variant6-color; +} + +.mx_Username_color7 { + color: $username-variant7-color; +} + +.mx_Username_color8 { + color: $username-variant8-color; +} + diff --git a/res/css/_components.scss b/res/css/_components.scss index 4fb0eed4af..6e681894e3 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -6,6 +6,7 @@ @import "./structures/_CreateRoom.scss"; @import "./structures/_CustomRoomTagPanel.scss"; @import "./structures/_FilePanel.scss"; +@import "./structures/_GenericErrorPage.scss"; @import "./structures/_GroupView.scss"; @import "./structures/_HeaderButtons.scss"; @import "./structures/_HomePage.scss"; @@ -19,6 +20,7 @@ @import "./structures/_RoomStatusBar.scss"; @import "./structures/_RoomSubList.scss"; @import "./structures/_RoomView.scss"; +@import "./structures/_ScrollPanel.scss"; @import "./structures/_SearchBox.scss"; @import "./structures/_TabbedView.scss"; @import "./structures/_TagPanel.scss"; @@ -46,11 +48,11 @@ @import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss"; @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/_ChatInviteDialog.scss"; @import "./views/dialogs/_ConfirmUserActionDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss"; @import "./views/dialogs/_CreateRoomDialog.scss"; @@ -69,7 +71,9 @@ @import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_UnknownDeviceDialog.scss"; +@import "./views/dialogs/_UploadConfirmDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; +@import "./views/dialogs/_WidgetOpenIDPermissionsDialog.scss"; @import "./views/dialogs/keybackup/_CreateKeyBackupDialog.scss"; @import "./views/dialogs/keybackup/_KeyBackupFailedDialog.scss"; @import "./views/dialogs/keybackup/_RestoreKeyBackupDialog.scss"; @@ -96,6 +100,7 @@ @import "./views/elements/_ToggleSwitch.scss"; @import "./views/elements/_ToolTipButton.scss"; @import "./views/elements/_Tooltip.scss"; +@import "./views/elements/_Validation.scss"; @import "./views/globals/_MatrixToolbar.scss"; @import "./views/groups/_GroupPublicityToggle.scss"; @import "./views/groups/_GroupRoomList.scss"; @@ -108,7 +113,11 @@ @import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MTextBody.scss"; +@import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; +@import "./views/messages/_ReactionDimension.scss"; +@import "./views/messages/_ReactionsRow.scss"; +@import "./views/messages/_ReactionsRowButton.scss"; @import "./views/messages/_RoomAvatarEvent.scss"; @import "./views/messages/_SenderProfile.scss"; @import "./views/messages/_TextualEvent.scss"; diff --git a/res/css/structures/_AutoHideScrollbar.scss b/res/css/structures/_AutoHideScrollbar.scss index 0e1faf727d..db86a6fbd6 100644 --- a/res/css/structures/_AutoHideScrollbar.scss +++ b/res/css/structures/_AutoHideScrollbar.scss @@ -14,6 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ +/* This file has CSS for both native and non-native scrollbars in an + * order that's fairly logic to read but violates stylelints descending + * specificity rule, so turn it off for this file. It also duplicates + * a selector to separate the hiding/showing from the sizing. + */ +/* stylelint-disable no-descending-specificity, no-duplicate-selectors */ + /* 1. for browsers that support native overlay auto-hiding scrollbars */ @@ -59,8 +66,7 @@ body.mx_scrollbar_nooverlay { */ .mx_AutoHideScrollbar:hover.mx_AutoHideScrollbar_overflow > .mx_AutoHideScrollbar_offset, .mx_AutoHideScrollbar:hover.mx_AutoHideScrollbar_overflow::before, - .mx_AutoHideScrollbar:hover.mx_AutoHideScrollbar_overflow::after - { + .mx_AutoHideScrollbar:hover.mx_AutoHideScrollbar_overflow::after { margin-right: calc(-1 * var(--scrollbar-width)); } } diff --git a/res/css/structures/_CompatibilityPage.scss b/res/css/structures/_CompatibilityPage.scss index f3f032c975..26354ed124 100644 --- a/res/css/structures/_CompatibilityPage.scss +++ b/res/css/structures/_CompatibilityPage.scss @@ -16,4 +16,4 @@ border: 1px solid; padding: 10px; background-color: #fcc; -} \ No newline at end of file +} diff --git a/res/css/structures/_ContextualMenu.scss b/res/css/structures/_ContextualMenu.scss index 3788929bf3..fc1538a13d 100644 --- a/res/css/structures/_ContextualMenu.scss +++ b/res/css/structures/_ContextualMenu.scss @@ -35,7 +35,7 @@ limitations under the License. background-color: $menu-bg-color; color: $primary-fg-color; position: absolute; - font-size: 14px; + font-size: 14px; z-index: 5001; } @@ -54,14 +54,14 @@ limitations under the License. border-bottom: 8px solid transparent; } -.mx_ContextualMenu_chevron_right:after { - content:''; +.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; + position: absolute; top: -7px; right: 1px; } @@ -81,14 +81,14 @@ limitations under the License. border-bottom: 8px solid transparent; } -.mx_ContextualMenu_chevron_left:after{ - content:''; +.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; + position: absolute; top: -7px; left: 1px; } @@ -108,14 +108,14 @@ limitations under the License. border-right: 8px solid transparent; } -.mx_ContextualMenu_chevron_top:after{ - content:''; +.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; + position: absolute; left: -7px; top: 1px; } @@ -135,14 +135,14 @@ limitations under the License. border-right: 8px solid transparent; } -.mx_ContextualMenu_chevron_bottom:after{ - content:''; +.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; + position: absolute; left: -7px; bottom: 1px; } diff --git a/res/css/structures/_CreateRoom.scss b/res/css/structures/_CreateRoom.scss index 2be193525e..10f9e23a02 100644 --- a/res/css/structures/_CreateRoom.scss +++ b/res/css/structures/_CreateRoom.scss @@ -22,7 +22,7 @@ limitations under the License. } .mx_CreateRoom input, -.mx_CreateRoom textarea { +.mx_CreateRoom textarea { border-radius: 3px; border: 1px solid $strong-input-border-color; font-weight: 300; diff --git a/res/css/structures/_FilePanel.scss b/res/css/structures/_FilePanel.scss index 677fa34c6f..703e90f402 100644 --- a/res/css/structures/_FilePanel.scss +++ b/res/css/structures/_FilePanel.scss @@ -101,10 +101,10 @@ limitations under the License. padding-left: 0px; } -.mx_FilePanel .mx_EventTile:hover .mx_EventTile_line { - background-color: $primary-bg-color; -} - .mx_FilePanel .mx_EventTile_selected .mx_EventTile_line { padding-left: 0px; } + +.mx_FilePanel .mx_EventTile:hover .mx_EventTile_line { + background-color: $primary-bg-color; +} diff --git a/res/css/structures/_GenericErrorPage.scss b/res/css/structures/_GenericErrorPage.scss new file mode 100644 index 0000000000..9c973af411 --- /dev/null +++ b/res/css/structures/_GenericErrorPage.scss @@ -0,0 +1,19 @@ +.mx_GenericErrorPage { + width: 100%; + height: 100%; + background-color: #fff; +} + +.mx_GenericErrorPage_box { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: auto; + width: 500px; + height: 200px; + border: 1px solid #f22; + padding: 10px; + background-color: #fcc; +} diff --git a/res/css/structures/_GroupView.scss b/res/css/structures/_GroupView.scss index 4f33617344..ae86f68fd0 100644 --- a/res/css/structures/_GroupView.scss +++ b/res/css/structures/_GroupView.scss @@ -67,13 +67,13 @@ limitations under the License. } .mx_GroupView_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_GroupView_editable:focus { - border-bottom: 1px solid $accent-color ! important; + border-bottom: 1px solid $accent-color !important; outline: none; box-shadow: none; } @@ -95,7 +95,7 @@ limitations under the License. .mx_GroupView_avatarPicker .mx_Spinner { width: 48px; - height: 48px ! important; + height: 48px !important; } .mx_GroupView_header_leftCol { @@ -176,7 +176,7 @@ limitations under the License. flex: 1; } -.mx_GroupView_body { +.mx_GroupView_body { flex-grow: 1; } @@ -333,7 +333,7 @@ limitations under the License. display: none; } -.mx_GroupView_body .gm-scroll-view > *{ +.mx_GroupView_body .gm-scroll-view > * { margin: 11px 50px 0px 68px; } diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 9dbfe696a5..a8d8669285 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -67,11 +67,6 @@ limitations under the License. z-index: 6; } -.mx_LeftPanel_container.collapsed .mx_BottomLeftMenu { - flex: 0 0 160px; - margin-bottom: 9px; -} - .mx_LeftPanel .mx_BottomLeftMenu { order: 3; @@ -82,6 +77,11 @@ limitations under the License. z-index: 1; } +.mx_LeftPanel_container.collapsed .mx_BottomLeftMenu { + flex: 0 0 160px; + margin-bottom: 9px; +} + .mx_LeftPanel .mx_BottomLeftMenu_options { margin-top: 18px; } diff --git a/res/css/structures/_MainSplit.scss b/res/css/structures/_MainSplit.scss index 28c89fe7ca..4d73953cd7 100644 --- a/res/css/structures/_MainSplit.scss +++ b/res/css/structures/_MainSplit.scss @@ -19,3 +19,9 @@ limitations under the License. flex-direction: row; min-width: 0; } + +// move hit area 5px to the right so it doesn't overlap with the timeline scrollbar +.mx_MainSplit > .mx_ResizeHandle.mx_ResizeHandle_horizontal { + margin: 0 -10px 0 0; + padding: 0 10px 0 0; +} diff --git a/res/css/structures/_MyGroups.scss b/res/css/structures/_MyGroups.scss index 4428eadc48..d25789ab94 100644 --- a/res/css/structures/_MyGroups.scss +++ b/res/css/structures/_MyGroups.scss @@ -52,7 +52,7 @@ limitations under the License. background-color: $roomheader-addroom-bg-color; position: relative; - &:before { + &::before { background-color: $roomheader-addroom-fg-color; mask: url('$(res)/img/icons-create-room.svg'); mask-repeat: no-repeat; @@ -113,8 +113,7 @@ limitations under the License. overflow-x: hidden; display: flex; - flex-direction: row; - flex-flow: wrap; + flex-flow: row wrap; align-content: flex-start; } @@ -153,6 +152,7 @@ limitations under the License. .mx_GroupTile_profile .mx_GroupTile_groupId { font-size: 13px; + opacity: 0.7; } .mx_GroupTile_profile .mx_GroupTile_desc { @@ -163,7 +163,3 @@ limitations under the License. max-height: 36px; overflow: hidden; } - -.mx_GroupTile_profile .mx_GroupTile_groupId { - opacity: 0.7; -} diff --git a/res/css/structures/_NotificationPanel.scss b/res/css/structures/_NotificationPanel.scss index b171aa3e36..78b3522d4e 100644 --- a/res/css/structures/_NotificationPanel.scss +++ b/res/css/structures/_NotificationPanel.scss @@ -66,7 +66,7 @@ limitations under the License. .mx_NotificationPanel .mx_EventTile_roomName a, .mx_NotificationPanel .mx_EventTile_senderDetails a { - text-decoration: none ! important; + text-decoration: none !important; } .mx_NotificationPanel .mx_EventTile .mx_MessageTimestamp { @@ -83,14 +83,14 @@ limitations under the License. padding-right: 0px; } -.mx_NotificationPanel .mx_EventTile:hover .mx_EventTile_line { - background-color: $primary-bg-color; -} - .mx_NotificationPanel .mx_EventTile_selected .mx_EventTile_line { padding-left: 0px; } +.mx_NotificationPanel .mx_EventTile:hover .mx_EventTile_line { + background-color: $primary-bg-color; +} + .mx_NotificationPanel .mx_EventTile_content { margin-right: 0px; } diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index 1054654670..090a40235f 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -40,12 +40,12 @@ limitations under the License. opacity: 0.5; position: relative; top: -4px; -/* + /* animation-duration: 1s; animation-name: bounce; animation-direction: alternate; animation-iteration-count: infinite; -*/ + */ } .mx_RoomStatusBar_placeholderIndicator span:nth-child(1) { @@ -138,8 +138,8 @@ limitations under the License. } .mx_RoomStatusBar_resend_link { - color: $primary-fg-color ! important; - text-decoration: underline ! important; + color: $primary-fg-color !important; + text-decoration: underline !important; cursor: pointer; } @@ -173,12 +173,12 @@ limitations under the License. } .mx_RoomStatusBar_callBar { - height: 40px; - line-height: 40px; + height: 40px; + line-height: 40px; } .mx_RoomStatusBar_typingBar { - height: 40px; - line-height: 40px; + height: 40px; + line-height: 40px; } } diff --git a/res/css/structures/_RoomSubList.scss b/res/css/structures/_RoomSubList.scss index 2f1484d83f..15fddba817 100644 --- a/res/css/structures/_RoomSubList.scss +++ b/res/css/structures/_RoomSubList.scss @@ -89,7 +89,7 @@ limitations under the License. flex: 0 0 16px; position: relative; - &:before { + &::before { background-color: $roomheader-addroom-fg-color; mask: url('$(res)/img/icons-room-add.svg'); mask-repeat: no-repeat; @@ -137,6 +137,26 @@ limitations under the License. padding: 0 8px; } +.collapsed { + .mx_RoomSubList_scroll { + padding: 0; + } + + .mx_RoomSubList_labelContainer { + margin-right: 14px; + margin-left: 2px; + } + + .mx_RoomSubList_addRoom { + margin-left: 3px; + margin-right: 10px; + } + + .mx_RoomSubList_label > span { + display: none; + } +} + // overflow indicators .mx_RoomSubList:not(.resized-all) > .mx_RoomSubList_scroll { &.mx_IndicatorScrollbar_topOverflow::before, @@ -164,7 +184,7 @@ limitations under the License. background: linear-gradient(to top, $panel-gradient); } -/* + /* // for now, we remove the bottomOverflow entirely as we don't want to // lose the screen real-estate due to a bg-colored gradient, but we also // don't want to use drop shadows and risk a confusing hierarchy of cards. @@ -175,26 +195,5 @@ limitations under the License. margin: 0px -8px; background: linear-gradient(to bottom, rgba(0,0,0,0.1), rgba(0,0,0,0.0)); } -*/ -} - -.collapsed { - - .mx_RoomSubList_scroll { - padding: 0; - } - - .mx_RoomSubList_labelContainer { - margin-right: 14px; - margin-left: 2px; - } - - .mx_RoomSubList_addRoom { - margin-left: 3px; - margin-right: 10px; - } - - .mx_RoomSubList_label > span { - display: none; - } + */ } diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index f15552e484..50d412ad58 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -70,8 +70,15 @@ limitations under the License. background-color: $primary-bg-color; } +.mx_RoomView_auxPanel_hiddenHighlights { + border-bottom: 1px solid $primary-hairline-color; + padding: 10px 26px; + color: $warning-color; + cursor: pointer; +} + .mx_RoomView_auxPanel_apps { - max-width: 1920px ! important; + max-width: 1920px !important; } @@ -79,38 +86,11 @@ limitations under the License. flex: 1 1 0; } -.mx_RoomView_body { - position: relative; //for .mx_RoomView_auxPanel_fullHeight - display: flex; - flex-direction: column; - flex: 1; -} - -.mx_RoomView_body .mx_RoomView_timeline { - /* offset parent for mx_RoomView_topUnreadMessagesBar */ - position: relative; - flex: 1; - display: flex; - flex-direction: column; -} - -.mx_RoomView_body { - .mx_RoomView_messagePanel, .mx_RoomView_messagePanelSpinner, .mx_RoomView_messagePanelSearchSpinner{ - order: 2; - } -} - -.mx_RoomView_body .mx_RoomView_statusArea { - order: 3; -} - -.mx_RoomView_body .mx_MessageComposer { - order: 4; -} - .mx_RoomView_messagePanel { width: 100%; overflow-y: auto; + flex: 1 1 0; + overflow-anchor: none; } .mx_RoomView_messagePanelSearchSpinner { @@ -122,7 +102,7 @@ limitations under the License. position: relative; } -.mx_RoomView_messagePanelSearchSpinner:before { +.mx_RoomView_messagePanelSearchSpinner::before { background-color: $greyed-fg-color; mask: url('$(res)/img/feather-customised/search-input.svg'); mask-repeat: no-repeat; @@ -136,6 +116,53 @@ limitations under the License. height: 50px; } +.mx_RoomView_body { + position: relative; //for .mx_RoomView_auxPanel_fullHeight + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + + .mx_RoomView_messagePanel, .mx_RoomView_messagePanelSpinner, .mx_RoomView_messagePanelSearchSpinner { + order: 2; + } +} + +.mx_RoomView_body .mx_RoomView_timeline { + /* offset parent for mx_RoomView_topUnreadMessagesBar */ + position: relative; + flex: 1; + display: flex; + flex-direction: column; +} + +.mx_RoomView_statusArea { + width: 100%; + flex: 0 0 auto; + + max-height: 0px; + background-color: $primary-bg-color; + z-index: 1000; + overflow: hidden; + + transition: all .2s ease-out; +} + +.mx_RoomView_statusArea_expanded { + max-height: 100px; +} + +.mx_RoomView_statusAreaBox { + margin: auto; + min-height: 50px; +} + +.mx_RoomView_statusAreaBox_line { + margin-left: 65px; + border-top: 1px solid $primary-hairline-color; + height: 1px; +} + .mx_RoomView_messageListWrapper { min-height: 100%; @@ -196,33 +223,6 @@ hr.mx_RoomView_myReadMarker { z-index: 1; } -.mx_RoomView_statusArea { - width: 100%; - flex: 0 0 auto; - - max-height: 0px; - background-color: $primary-bg-color; - z-index: 1000; - overflow: hidden; - - transition: all .2s ease-out; -} - -.mx_RoomView_statusArea_expanded { - max-height: 100px; -} - -.mx_RoomView_statusAreaBox { - margin: auto; - min-height: 50px; -} - -.mx_RoomView_statusAreaBox_line { - margin-left: 65px; - border-top: 1px solid $primary-hairline-color; - height: 1px; -} - .mx_RoomView_callStatusBar .mx_UploadBar_uploadProgressInner { background-color: $primary-bg-color; } @@ -283,7 +283,7 @@ hr.mx_RoomView_myReadMarker { } .mx_RoomView_ongoingConfCallNotification a { - color: $accent-fg-color ! important; + color: $accent-fg-color !important; } .mx_MatrixChat_useCompactLayout { diff --git a/res/css/structures/_ScrollPanel.scss b/res/css/structures/_ScrollPanel.scss new file mode 100644 index 0000000000..699224949b --- /dev/null +++ b/res/css/structures/_ScrollPanel.scss @@ -0,0 +1,26 @@ +/* +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_ScrollPanel { + + .mx_RoomView_MessageList { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; + overflow-y: hidden; + } +} diff --git a/res/css/structures/_TabbedView.scss b/res/css/structures/_TabbedView.scss index 29e7c401e6..7904df5a82 100644 --- a/res/css/structures/_TabbedView.scss +++ b/res/css/structures/_TabbedView.scss @@ -50,7 +50,7 @@ limitations under the License. color: $tab-label-active-fg-color; } -.mx_TabbedView_maskedIcon {; +.mx_TabbedView_maskedIcon { margin-left: 6px; margin-right: 9px; margin-top: 1px; @@ -59,7 +59,7 @@ limitations under the License. display: inline-block; } -.mx_TabbedView_maskedIcon:before { +.mx_TabbedView_maskedIcon::before { display: inline-block; background-color: $tab-label-icon-bg-color; mask-repeat: no-repeat; @@ -71,7 +71,7 @@ limitations under the License. vertical-align: middle; } -.mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon:before { +.mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { background-color: $tab-label-active-icon-bg-color; } @@ -91,4 +91,4 @@ limitations under the License. flex-grow: 1; overflow: auto; min-height: 0; // firefox -} \ No newline at end of file +} diff --git a/res/css/structures/_TagPanel.scss b/res/css/structures/_TagPanel.scss index 084dcbfb47..a818f52125 100644 --- a/res/css/structures/_TagPanel.scss +++ b/res/css/structures/_TagPanel.scss @@ -36,7 +36,6 @@ limitations under the License. flex: none; - display: flex; justify-content: center; align-items: flex-start; @@ -75,13 +74,13 @@ limitations under the License. .mx_TagPanel .mx_TagTile { margin: 9px 0; -// opacity: 0.5; + // opacity: 0.5; position: relative; } .mx_TagPanel .mx_TagTile:focus, .mx_TagPanel .mx_TagTile:hover, .mx_TagPanel .mx_TagTile.mx_TagTile_selected { -// opacity: 1; + // opacity: 1; } .mx_TagPanel .mx_TagTile.mx_TagTile_selected .mx_TagTile_avatar .mx_BaseAvatar { diff --git a/res/css/structures/_TagPanelButtons.scss b/res/css/structures/_TagPanelButtons.scss index b14bb10bf8..70fea92959 100644 --- a/res/css/structures/_TagPanelButtons.scss +++ b/res/css/structures/_TagPanelButtons.scss @@ -23,12 +23,12 @@ limitations under the License. padding: 17px 0 3px 0; } -.mx_TagPanelButtons > .mx_GroupsButton:before { +.mx_TagPanelButtons > .mx_GroupsButton::before { mask: url('$(res)/img/feather-customised/users.svg'); mask-position: center 11px; } -.mx_TagPanelButtons > .mx_TagPanelButtons_report:before { +.mx_TagPanelButtons > .mx_TagPanelButtons_report::before { mask: url('$(res)/img/feather-customised/life-buoy.svg'); mask-position: center 9px; } @@ -43,7 +43,7 @@ limitations under the License. /* overwrite mx_RoleButton inline-block */ display: block !important; - &:before { + &::before { background-color: $tagpanel-bg-color; mask-repeat: no-repeat; content: ''; diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index fa034095b6..16ac876869 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -130,3 +130,27 @@ limitations under the License. .mx_AuthBody_spinner { margin: 1em 0; } + +.mx_AuthBody_passwordScore { + width: 100%; + appearance: none; + height: 4px; + border: 0; + border-radius: 2px; + position: absolute; + top: -12px; + + &::-moz-progress-bar { + border-radius: 2px; + background-color: $accent-color; + } + + &::-webkit-progress-bar, + &::-webkit-progress-value { + border-radius: 2px; + } + + &::-webkit-progress-value { + background-color: $accent-color; + } +} diff --git a/res/css/views/avatars/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss index 91b2d6c426..a085034758 100644 --- a/res/css/views/avatars/_BaseAvatar.scss +++ b/res/css/views/avatars/_BaseAvatar.scss @@ -26,6 +26,7 @@ limitations under the License. // https://bugzilla.mozilla.org/show_bug.cgi?id=1535053 // https://bugzilla.mozilla.org/show_bug.cgi?id=255139 display: inline-block; + user-select: none; } .mx_BaseAvatar_initial { diff --git a/res/css/views/dialogs/_ChatInviteDialog.scss b/res/css/views/dialogs/_AddressPickerDialog.scss similarity index 83% rename from res/css/views/dialogs/_ChatInviteDialog.scss rename to res/css/views/dialogs/_AddressPickerDialog.scss index dcc0f5921a..b4d4a74cb5 100644 --- a/res/css/views/dialogs/_ChatInviteDialog.scss +++ b/res/css/views/dialogs/_AddressPickerDialog.scss @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket 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. @@ -15,8 +16,8 @@ limitations under the License. */ /* Using a textarea for this element, to circumvent autofill */ -.mx_ChatInviteDialog_input, -.mx_ChatInviteDialog_input:focus +.mx_AddressPickerDialog_input, +.mx_AddressPickerDialog_input:focus { height: 26px; font-size: 14px; @@ -34,11 +35,11 @@ limitations under the License. word-wrap: nowrap; } -.mx_ChatInviteDialog .mx_Dialog_content { +.mx_AddressPickerDialog .mx_Dialog_content { min-height: 50px } -.mx_ChatInviteDialog_inputContainer { +.mx_AddressPickerDialog_inputContainer { border-radius: 3px; border: solid 1px $input-border-color; line-height: 36px; @@ -51,19 +52,19 @@ limitations under the License. overflow-y: auto; } -.mx_ChatInviteDialog_error { +.mx_AddressPickerDialog_error { margin-top: 10px; color: $warning-color; } -.mx_ChatInviteDialog_cancel { +.mx_AddressPickerDialog_cancel { position: absolute; right: 11px; top: 13px; cursor: pointer; } -.mx_ChatInviteDialog_cancel object { +.mx_AddressPickerDialog_cancel object { pointer-events: none; } diff --git a/res/css/views/dialogs/_BugReportDialog.scss b/res/css/views/dialogs/_BugReportDialog.scss index e00d446eda..90ef55b945 100644 --- a/res/css/views/dialogs/_BugReportDialog.scss +++ b/res/css/views/dialogs/_BugReportDialog.scss @@ -14,39 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_BugReportDialog_field_container { - display: flex; -} - -.mx_BugReportDialog_field_label { - flex-basis: 150px; - - text-align: right; - - padding-top: 9px; - padding-right: 4px; - - line-height: 18px; +.mx_BugReportDialog .mx_Field { + flex: 1; } .mx_BugReportDialog_field_input { - flex-grow: 1; - - /* taken from mx_ChatInviteDialog_inputContainer */ - border-radius: 3px; - border: solid 1px $input-border-color; - - font-size: 14px; - - padding-left: 4px; - padding-right: 4px; - padding-top: 7px; - padding-bottom: 7px; - - margin-bottom: 4px; -} - -.mx_BugReportDialog_field_input[type="text" i] { - padding-top: 9px; - padding-bottom: 9px; + // TODO: We should really apply this to all .mx_Field inputs. + // See https://github.com/vector-im/riot-web/issues/9344. + flex: 1; } diff --git a/res/css/views/dialogs/_SettingsDialog.scss b/res/css/views/dialogs/_SettingsDialog.scss index abf0048cfd..ec813a1a07 100644 --- a/res/css/views/dialogs/_SettingsDialog.scss +++ b/res/css/views/dialogs/_SettingsDialog.scss @@ -14,34 +14,29 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_SettingsDialog { - .mx_Dialog { - max-width: 1000px; - width: 90%; - height: 80%; - border-radius: 4px; - padding-top: 0; - padding-right: 0; - padding-left: 0; +// Not actually a component but things shared by settings components +.mx_UserSettingsDialog, .mx_RoomSettingsDialog { + width: 90vw; + max-width: 1000px; + // set the height too since tabbed view scrolls itself. + height: 80vh; - .mx_TabbedView { - top: 65px; - } + .mx_TabbedView { + top: 65px; + } - .mx_TabbedView .mx_SettingsTab { - box-sizing: border-box; - min-width: 580px; - padding-right: 130px; + .mx_TabbedView .mx_SettingsTab { + box-sizing: border-box; + min-width: 580px; + padding-right: 100px; - // Put some padding on the bottom to avoid the settings tab from - // colliding harshly with the dialog when scrolled down. - padding-bottom: 100px; - } + // Put some padding on the bottom to avoid the settings tab from + // colliding harshly with the dialog when scrolled down. + padding-bottom: 100px; + } - .mx_Dialog_title { - text-align: center; - margin-top: 16px; - margin-bottom: 24px; - } + .mx_Dialog_title { + text-align: center; + margin-bottom: 24px; } } diff --git a/res/css/views/dialogs/_UploadConfirmDialog.scss b/res/css/views/dialogs/_UploadConfirmDialog.scss new file mode 100644 index 0000000000..cf9736ce81 --- /dev/null +++ b/res/css/views/dialogs/_UploadConfirmDialog.scss @@ -0,0 +1,35 @@ +/* +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_UploadConfirmDialog_fileIcon { + margin-right: 5px; +} + +.mx_UploadConfirmDialog_previewOuter { + text-align: center; +} + +.mx_UploadConfirmDialog_previewInner { + display: inline-block; + text-align: left; +} + +.mx_UploadConfirmDialog_imagePreview { + max-height: 300px; + max-width: 100%; + border-radius: 4px; + border: 1px solid $dialog-close-fg-color; +} diff --git a/res/css/views/dialogs/_WidgetOpenIDPermissionsDialog.scss b/res/css/views/dialogs/_WidgetOpenIDPermissionsDialog.scss new file mode 100644 index 0000000000..a419c105a9 --- /dev/null +++ b/res/css/views/dialogs/_WidgetOpenIDPermissionsDialog.scss @@ -0,0 +1,28 @@ +/* +Copyright 2019 Travis Ralston + +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_WidgetOpenIDPermissionsDialog .mx_SettingsFlag { + .mx_ToggleSwitch { + display: inline-block; + vertical-align: middle; + margin-right: 8px; + } + + .mx_SettingsFlag_label { + display: inline-block; + vertical-align: middle; + } +} diff --git a/res/css/views/elements/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index 25ad51a3fb..fe1f283009 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -37,6 +37,12 @@ limitations under the License. .mx_AccessibleButton_kind_primary { color: $button-primary-fg-color; background-color: $button-primary-bg-color; + font-weight: 600; +} + +.mx_AccessibleButton_kind_secondary { + color: $accent-color; + font-weight: 600; } .mx_AccessibleButton_kind_primary.mx_AccessibleButton_disabled { diff --git a/res/css/views/elements/_Field.scss b/res/css/views/elements/_Field.scss index 20b1efd28b..147bb3b471 100644 --- a/res/css/views/elements/_Field.scss +++ b/res/css/views/elements/_Field.scss @@ -168,6 +168,7 @@ limitations under the License. .mx_Field_tooltip { margin-top: -12px; margin-left: 4px; + width: 200px; } .mx_Field_tooltip.mx_Field_valid { diff --git a/res/css/views/elements/_ImageView.scss b/res/css/views/elements/_ImageView.scss index 52b6a63699..88cf2ce8ba 100644 --- a/res/css/views/elements/_ImageView.scss +++ b/res/css/views/elements/_ImageView.scss @@ -80,7 +80,24 @@ limitations under the License. // hack for mx_Dialog having a top padding of 40px top: 40px; right: 0px; - padding: 35px; + padding-top: 35px; + padding-right: 35px; + cursor: pointer; +} + +.mx_ImageView_rotateClockwise { + position: absolute; + top: 40px; + right: 70px; + padding-top: 35px; + cursor: pointer; +} + +.mx_ImageView_rotateCounterClockwise { + position: absolute; + top: 40px; + right: 105px; + padding-top: 35px; cursor: pointer; } diff --git a/res/css/views/elements/_Tooltip.scss b/res/css/views/elements/_Tooltip.scss index 78604b1564..43ddf6dde5 100644 --- a/res/css/views/elements/_Tooltip.scss +++ b/res/css/views/elements/_Tooltip.scss @@ -50,11 +50,10 @@ limitations under the License. .mx_Tooltip { display: none; - animation: mx_fadein 0.2s; position: fixed; border: 1px solid $menu-border-color; border-radius: 4px; - box-shadow: 4px 4px 12px 0 rgba(118, 131, 156, 0.6); + box-shadow: 4px 4px 12px 0 $menu-box-shadow-color; background-color: $menu-bg-color; z-index: 2000; padding: 10px; @@ -66,4 +65,12 @@ limitations under the License. max-width: 200px; word-break: break-word; margin-right: 50px; + + &.mx_Tooltip_visible { + animation: mx_fadein 0.2s forwards; + } + + &.mx_Tooltip_invisible { + animation: mx_fadeout 0.1s forwards; + } } diff --git a/res/css/views/elements/_Validation.scss b/res/css/views/elements/_Validation.scss new file mode 100644 index 0000000000..1f9bd880e6 --- /dev/null +++ b/res/css/views/elements/_Validation.scss @@ -0,0 +1,69 @@ +/* +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_Validation { + position: relative; +} + +.mx_Validation_details { + padding-left: 20px; + margin: 0; +} + +.mx_Validation_description + .mx_Validation_details { + margin: 1em 0 0; +} + +.mx_Validation_detail { + position: relative; + font-weight: normal; + list-style: none; + margin-bottom: 0.5em; + + &:last-child { + margin-bottom: 0; + } + + &::before { + content: ""; + position: absolute; + width: 14px; + height: 14px; + top: 0; + left: -18px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + + &.mx_Validation_valid { + color: $input-valid-border-color; + + &::before { + mask-image: url('$(res)/img/feather-customised/check.svg'); + background-color: $input-valid-border-color; + } + } + + &.mx_Validation_invalid { + color: $input-invalid-border-color; + + &::before { + mask-image: url('$(res)/img/feather-customised/x.svg'); + background-color: $input-invalid-border-color; + } + } +} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss new file mode 100644 index 0000000000..419542036e --- /dev/null +++ b/res/css/views/messages/_MessageActionBar.scss @@ -0,0 +1,74 @@ +/* +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_MessageActionBar { + position: absolute; + visibility: hidden; + cursor: pointer; + display: flex; + height: 24px; + line-height: 24px; + border-radius: 4px; + background: $message-action-bar-bg-color; + top: -13px; + right: 8px; + user-select: none; + + > * { + display: inline-block; + position: relative; + width: 27px; + border: 1px solid $message-action-bar-border-color; + margin-left: -1px; + + &:hover { + border-color: $message-action-bar-hover-border-color; + z-index: 1; + } + + &:first-child { + border-radius: 3px 0 0 3px; + } + + &:last-child { + border-radius: 0 3px 3px 0; + } + + &:only-child { + border-radius: 3px; + } + } +} + +.mx_MessageActionBar_maskButton::after { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + mask-repeat: no-repeat; + mask-position: center; + background-color: $message-action-bar-fg-color; +} + +.mx_MessageActionBar_replyButton::after { + mask-image: url('$(res)/img/reply.svg'); +} + +.mx_MessageActionBar_optionsButton::after { + mask-image: url('$(res)/img/icon_context.svg'); +} diff --git a/res/css/views/messages/_ReactionDimension.scss b/res/css/views/messages/_ReactionDimension.scss new file mode 100644 index 0000000000..9a891d05cf --- /dev/null +++ b/res/css/views/messages/_ReactionDimension.scss @@ -0,0 +1,25 @@ +/* +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_ReactionDimension { + width: 42px; + display: flex; + justify-content: space-evenly; +} + +.mx_ReactionDimension_disabled { + opacity: 0.4; +} diff --git a/res/css/views/messages/_ReactionsRow.scss b/res/css/views/messages/_ReactionsRow.scss new file mode 100644 index 0000000000..fb66ffbb8c --- /dev/null +++ b/res/css/views/messages/_ReactionsRow.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_ReactionsRow { + margin: 6px 0; +} diff --git a/res/css/views/messages/_ReactionsRowButton.scss b/res/css/views/messages/_ReactionsRowButton.scss new file mode 100644 index 0000000000..49e3930979 --- /dev/null +++ b/res/css/views/messages/_ReactionsRowButton.scss @@ -0,0 +1,36 @@ +/* +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_ReactionsRowButton { + display: inline-block; + height: 20px; + line-height: 21px; + margin-right: 6px; + padding: 0 6px; + border: 1px solid $reaction-row-button-border-color; + border-radius: 10px; + background-color: $reaction-row-button-bg-color; + cursor: pointer; + + &:hover { + border-color: $reaction-row-button-hover-border-color; + } + + &.mx_ReactionsRowButton_selected { + background-color: $reaction-row-button-selected-bg-color; + border-color: $reaction-row-button-selected-border-color; + } +} diff --git a/res/css/views/messages/_SenderProfile.scss b/res/css/views/messages/_SenderProfile.scss index a4a2aba11f..655cb39489 100644 --- a/res/css/views/messages/_SenderProfile.scss +++ b/res/css/views/messages/_SenderProfile.scss @@ -18,36 +18,3 @@ limitations under the License. font-weight: 600; } -.mx_SenderProfile_color1 { - color: $username-variant1-color; -} - -.mx_SenderProfile_color2 { - color: $username-variant2-color; -} - -.mx_SenderProfile_color3 { - color: $username-variant3-color; -} - -.mx_SenderProfile_color4 { - color: $username-variant4-color; -} - -.mx_SenderProfile_color5 { - color: $username-variant5-color; -} - -.mx_SenderProfile_color6 { - color: $username-variant6-color; -} - -.mx_SenderProfile_color7 { - color: $username-variant7-color; -} - -.mx_SenderProfile_color8 { - color: $username-variant8-color; -} - - diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 42eab24051..f4c12bb734 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -31,6 +31,7 @@ limitations under the License. top: 14px; left: 8px; cursor: pointer; + user-select: none; } .mx_EventTile.mx_EventTile_info .mx_EventTile_avatar { @@ -62,6 +63,7 @@ limitations under the License. vertical-align: top; height: 16px; overflow: hidden; + user-select: none; img { vertical-align: -2px; @@ -80,6 +82,7 @@ limitations under the License. width: 46px; /* 8 + 30 (avatar) + 8 */ text-align: center; position: absolute; + user-select: none; } .mx_EventTile_line, .mx_EventTile_reply { @@ -118,7 +121,7 @@ limitations under the License. } .mx_EventTile:hover .mx_EventTile_line, -.mx_EventTile.menu .mx_EventTile_line +.mx_EventTile.mx_EventTile_actionBarFocused .mx_EventTile_line { background-color: $event-selected-color; } @@ -203,7 +206,7 @@ limitations under the License. // 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.menu > div > a > .mx_MessageTimestamp { +.mx_EventTile.mx_EventTile_actionBarFocused > div > a > .mx_MessageTimestamp { visibility: visible; } @@ -216,24 +219,8 @@ limitations under the License. width: auto; } -.mx_EventTile_editButton { - position: absolute; - display: inline-block; - visibility: hidden; - cursor: pointer; - top: 6px; - right: 6px; - width: 19px; - height: 19px; - background-image: url($edit-button-url); - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -.mx_EventTile:hover .mx_EventTile_editButton, -.mx_EventTile.menu .mx_EventTile_editButton { +.mx_EventTile:hover .mx_MessageActionBar, +.mx_EventTile.mx_EventTile_actionBarFocused .mx_MessageActionBar { visibility: visible; } @@ -243,6 +230,7 @@ limitations under the License. width: 14px; height: 14px; top: 29px; + user-select: none; } .mx_EventTile_continuation .mx_EventTile_readAvatars, @@ -550,10 +538,6 @@ limitations under the License. top: 3px; } - .mx_EventTile_editButton { - top: 3px; - } - .mx_EventTile_readAvatars { top: 27px; } diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss index 9f2b5da930..cac97cb60d 100644 --- a/res/css/views/rooms/_MemberList.scss +++ b/res/css/views/rooms/_MemberList.scss @@ -20,6 +20,7 @@ limitations under the License. flex: 1; display: flex; flex-direction: column; + min-height: 0; .mx_Spinner { flex: 1 0 auto; @@ -35,6 +36,10 @@ limitations under the License. margin-top: 8px; margin-bottom: 4px; } + + .mx_AutoHideScrollbar { + flex: 1 1 0; + } } .mx_MemberList_chevron { diff --git a/res/css/views/rooms/_RoomBreadcrumbs.scss b/res/css/views/rooms/_RoomBreadcrumbs.scss index c5a149d5cd..6c3eb0420a 100644 --- a/res/css/views/rooms/_RoomBreadcrumbs.scss +++ b/res/css/views/rooms/_RoomBreadcrumbs.scss @@ -15,33 +15,47 @@ limitations under the License. */ .mx_RoomBreadcrumbs { - overflow-x: auto; position: relative; - height: 32px; - margin: 8px; - margin-bottom: 0; - - overflow-x: hidden; + height: 42px; + padding: 8px; + padding-bottom: 0; display: flex; flex-direction: row; - > * { - margin-left: 4px; + // Autohide the scrollbar + overflow-x: hidden; + &:hover { + overflow-x: visible; } - &::after { - content: ""; - position: absolute; - width: 15px; - top: 0; - right: 0; + .mx_AutoHideScrollbar_offset { + display: flex; + flex-direction: row; height: 100%; - background: linear-gradient(to right, $panel-gradient); + } + + .mx_RoomBreadcrumbs_crumb { + margin-left: 4px; + height: 32px; + display: inline-block; + transition: transform 0.3s, width 0.3s; + position: relative; + + .mx_RoomTile_badge { + position: absolute; + top: -3px; + right: -4px; + } + + .mx_RoomBreadcrumbs_dmIndicator { + position: absolute; + bottom: 0; + right: -4px; + } } .mx_RoomBreadcrumbs_animate { margin-left: 0; - transition: transform 0.3s, width 0.3s; width: 32px; transform: scale(1); } @@ -50,5 +64,37 @@ limitations under the License. width: 0; transform: scale(0); } + + .mx_RoomBreadcrumbs_left { + opacity: 0.5; + } + + // Note: we have to manually control the gradient and stuff, but the IndicatorScrollbar + // will deal with left/right positioning for us. Normally we'd use position:sticky on + // a few key elements, however that doesn't work in horizontal scrolling scenarios. + + .mx_IndicatorScrollbar_leftOverflowIndicator, + .mx_IndicatorScrollbar_rightOverflowIndicator { + display: none; + } + + &.mx_IndicatorScrollbar_leftOverflow .mx_IndicatorScrollbar_leftOverflowIndicator, + &.mx_IndicatorScrollbar_rightOverflow .mx_IndicatorScrollbar_rightOverflowIndicator { + position: absolute; + top: 0; + bottom: 0; + width: 15px; + display: block; + 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/_RoomPreviewBar.scss b/res/css/views/rooms/_RoomPreviewBar.scss index 8196740499..ea3b787971 100644 --- a/res/css/views/rooms/_RoomPreviewBar.scss +++ b/res/css/views/rooms/_RoomPreviewBar.scss @@ -15,48 +15,112 @@ limitations under the License. */ .mx_RoomPreviewBar { - text-align: center; - height: 176px; - background-color: $event-selected-color; + flex: 0 0 auto; align-items: center; flex-direction: column; justify-content: center; display: flex; - background-color: $preview-bar-bg-color; -webkit-align-items: center; + + h3 { + font-size: 18px; + font-weight: 600; + + &.mx_RoomPreviewBar_spinnerTitle { + display: flex; + flex-direction: row; + align-items: center; + } + } + + .mx_Spinner { + width: auto; + height: auto; + margin: 10px 10px 10px 0; + flex: 0 0 auto; + } } -.mx_RoomPreviewBar_wrapper { +.mx_RoomPreviewBar_dark { + background-color: $tagpanel-bg-color; + color: $accent-fg-color; } -.mx_RoomPreviewBar_invite_text { - color: $primary-fg-color; +.mx_RoomPreviewBar_actions { + display: flex; } -.mx_RoomPreviewBar_join_text { - color: $warning-color; +.mx_RoomPreviewBar_message { + display: flex; + align-items: stretch; + + p { + overflow-wrap: break-word; + } } -.mx_RoomPreviewBar_preview_text { - margin-top: 25px; - color: $settings-grey-fg-color; +.mx_RoomPreviewBar_panel { + padding: 8px 8px 8px 20px; + border-top: 1px solid $panel-divider-color; + + flex-direction: row; + + .mx_RoomPreviewBar_actions { + flex: 0 0 auto; + flex-direction: row; + padding: 3px 8px; + + &>* { + margin-left: 12px; + } + } + + .mx_RoomPreviewBar_message { + flex: 1 0 0; + min-width: 0; + display: flex; + flex-direction: column; + + &>* { + margin: 4px; + } + } } -.mx_RoomPreviewBar_join_text a { +.mx_RoomPreviewBar_dialog { + margin: auto; + box-sizing: content; + width: 400px; + border-radius: 4px; + flex-direction: column; + padding: 20px; + text-align: center; + + .mx_RoomPreviewBar_message { + flex-direction: column; + + &>* { + margin: 5px 0 20px 0; + } + } + + .mx_RoomPreviewBar_actions { + flex-direction: column-reverse; + .mx_AccessibleButton { + padding: 7px 50px;//extra wide + } + + &>* { + margin-top: 12px; + } + } +} + +.mx_RoomPreviewBar_inviter { + font-weight: 600; +} + +a.mx_RoomPreviewBar_inviter { text-decoration: underline; cursor: pointer; } - -.mx_RoomPreviewBar_warning { - display: flex; - align-items: center; - padding: 8px; -} - -.mx_RoomPreviewBar_warningIcon { - padding: 12px; -} - -.mx_RoomPreviewBar_spinnerIntro { - margin-top: 50px; -} \ No newline at end of file diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index 97b2c48236..a1fc9bdca1 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -144,11 +144,14 @@ limitations under the License. font-size: 12px; } -.mx_RoomTile_unreadNotify .mx_RoomTile_badge { +.mx_RoomTile_unreadNotify .mx_RoomTile_badge, +.mx_RoomTile_badge.mx_RoomTile_badgeUnread { background-color: $roomtile-name-color; } -.mx_RoomTile_highlight .mx_RoomTile_badge { +.mx_RoomTile_highlight .mx_RoomTile_badge, +.mx_RoomTile_badge.mx_RoomTile_badgeRed +{ background-color: $warning-color; } diff --git a/res/css/views/rooms/_RoomUpgradeWarningBar.scss b/res/css/views/rooms/_RoomUpgradeWarningBar.scss index 82785b82d2..fe81d3801a 100644 --- a/res/css/views/rooms/_RoomUpgradeWarningBar.scss +++ b/res/css/views/rooms/_RoomUpgradeWarningBar.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_RoomUpgradeWarningBar { text-align: center; - height: 176px; + height: 235px; background-color: $event-selected-color; align-items: center; flex-direction: column; diff --git a/res/css/views/settings/tabs/_SettingsTab.scss b/res/css/views/settings/tabs/_SettingsTab.scss index 7fd8bceb50..def28bfbd2 100644 --- a/res/css/views/settings/tabs/_SettingsTab.scss +++ b/res/css/views/settings/tabs/_SettingsTab.scss @@ -14,6 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_SettingsTab_warningText { + color: $warning-color; +} + .mx_SettingsTab_heading { font-size: 20px; font-weight: 600; @@ -68,3 +72,7 @@ limitations under the License. // 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/img/feather-customised/check.svg b/res/img/feather-customised/check.svg new file mode 100644 index 0000000000..5c600f8649 --- /dev/null +++ b/res/img/feather-customised/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/feather-customised/x.svg b/res/img/feather-customised/x.svg new file mode 100644 index 0000000000..5468caa8aa --- /dev/null +++ b/res/img/feather-customised/x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/icon_context_message.svg b/res/img/icon_context_message.svg deleted file mode 100644 index f2ceccfa78..0000000000 --- a/res/img/icon_context_message.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - ED5D3E59-2561-4AC1-9B43-82FBC51767FC - Created with sketchtool. - - - - - - - - - - diff --git a/res/img/icons-home.svg b/res/img/icons-home.svg deleted file mode 100644 index eb5484c883..0000000000 --- a/res/img/icons-home.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - 81230A28-D944-4572-B5DB-C03CAA2B1FCA - Created with sketchtool. - - - - - - - - - - - - - - - - - diff --git a/res/img/icons-settings.svg b/res/img/icons-settings.svg deleted file mode 100644 index 3ca2b655f4..0000000000 --- a/res/img/icons-settings.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - 4D42A2A7-7430-4D4F-A0A2-E19278CF66E3 - Created with sketchtool. - - - - - - - - - - diff --git a/res/img/reply.svg b/res/img/reply.svg new file mode 100644 index 0000000000..8cbbad3550 --- /dev/null +++ b/res/img/reply.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/img/rotate-ccw.svg b/res/img/rotate-ccw.svg new file mode 100644 index 0000000000..3924eca040 --- /dev/null +++ b/res/img/rotate-ccw.svg @@ -0,0 +1 @@ + diff --git a/res/img/rotate-cw.svg b/res/img/rotate-cw.svg new file mode 100644 index 0000000000..91021c96d8 --- /dev/null +++ b/res/img/rotate-cw.svg @@ -0,0 +1 @@ + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 3112644a73..592b1a1887 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -40,7 +40,7 @@ $tagpanel-bg-color: $base-color; $selected-color: $room-highlight-color; // selected for hoverover & selected event tiles -$event-selected-color: #111316; +$event-selected-color: $header-panel-bg-color; // used for the hairline dividers in RoomView $primary-hairline-color: $header-panel-border-color; @@ -72,7 +72,7 @@ $avatar-bg-color: $bg-color; $h3-color: $primary-fg-color; -$dialog-title-fg-color: #454545; +$dialog-title-fg-color: $base-text-color; $dialog-backdrop-color: #000; $dialog-shadow-color: rgba(0, 0, 0, 0.48); $dialog-close-fg-color: #9fa9ba; @@ -146,6 +146,17 @@ $room-warning-bg-color: $header-panel-bg-color; $dark-panel-bg-color: $header-panel-bg-color; $panel-gradient: rgba(34, 38, 46, 0), rgba(34, 38, 46, 1); +$message-action-bar-bg-color: $header-panel-bg-color; +$message-action-bar-fg-color: $header-panel-text-primary-color; +$message-action-bar-border-color: #616b7f; +$message-action-bar-hover-border-color: $header-panel-text-primary-color; + +$reaction-row-button-bg-color: $header-panel-bg-color; +$reaction-row-button-border-color: #616b7f; +$reaction-row-button-hover-border-color: $header-panel-text-primary-color; +$reaction-row-button-selected-bg-color: #1f6954; +$reaction-row-button-selected-border-color: $accent-color; + // ***** Mixins! ***** @define-mixin mx_DialogButton { diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 879be67dda..adadd39333 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -11,7 +11,7 @@ $font-family: 'Nunito', Arial, Helvetica, Sans-Serif; $accent-color: #03b381; $notice-primary-color: #ff4b55; $notice-secondary-color: #61708b; -$header-panel-bg-color: #f2f5f8; +$header-panel-bg-color: #f3f8fd; // typical text (dark-on-white in light skin) $primary-fg-color: #2e2f32; @@ -66,14 +66,14 @@ $droptarget-bg-color: rgba(255,255,255,0.5); $selected-color: $secondary-accent-color; // selected for hoverover & selected event tiles -$event-selected-color: #f7f7f7; +$event-selected-color: $header-panel-bg-color; // used for the hairline dividers in RoomView $primary-hairline-color: #e5e5e5; // used for the border of input text fields $input-border-color: #e7e7e7; -$input-darker-bg-color: rgba(193, 201, 214, 0.29); +$input-darker-bg-color: #e3e8f0; $input-darker-fg-color: #9fa9ba; $input-lighter-bg-color: #f2f5f8; $input-lighter-fg-color: $input-darker-fg-color; @@ -106,10 +106,10 @@ $avatar-bg-color: #ffffff; $h3-color: #3d3b39; -$dialog-title-fg-color: #2e2f32; +$dialog-title-fg-color: #45474a; $dialog-backdrop-color: rgba(46, 48, 51, 0.38); $dialog-shadow-color: rgba(0, 0, 0, 0.48); -$dialog-close-fg-color: #9fa9ba; +$dialog-close-fg-color: #c1c1c1; $dialog-background-bg-color: #e9e9e9; $lightbox-background-bg-color: #000; @@ -153,7 +153,7 @@ $roomheader-button-color: #91A1C0; $groupheader-button-color: #91A1C0; $rightpanel-button-color: #91A1C0; $composer-button-color: #91A1C0; -$roomtopic-color: #9fa9ba; +$roomtopic-color: #9e9e9e; $eventtile-meta-color: $roomtopic-color; $composer-e2e-icon-color: #c9ced6; @@ -203,7 +203,6 @@ $event-redacted-border-color: #cccccc; // event timestamp $event-timestamp-color: #acacac; -$edit-button-url: "$(res)/img/icon_context_message.svg"; $copy-button-url: "$(res)/img/icon_copy_message.svg"; // e2e @@ -255,6 +254,17 @@ $authpage-secondary-color: #61708b; $dark-panel-bg-color: $secondary-accent-color; $panel-gradient: rgba(242, 245, 248, 0), rgba(242, 245, 248, 1); +$message-action-bar-bg-color: $primary-bg-color; +$message-action-bar-fg-color: $primary-fg-color; +$message-action-bar-border-color: #e9edf1; +$message-action-bar-hover-border-color: #b8c1d2; + +$reaction-row-button-bg-color: $header-panel-bg-color; +$reaction-row-button-border-color: #e9edf1; +$reaction-row-button-hover-border-color: #bebebe; +$reaction-row-button-selected-bg-color: #e9fff9; +$reaction-row-button-selected-border-color: $accent-color; + // ***** Mixins! ***** @define-mixin mx_DialogButton { diff --git a/scripts/ci/Dockerfile b/scripts/ci/Dockerfile new file mode 100644 index 0000000000..c153d11cc7 --- /dev/null +++ b/scripts/ci/Dockerfile @@ -0,0 +1,9 @@ +# Update on docker hub with the following commands in the directory of this file: +# docker build -t matrixdotorg/riotweb-ci-e2etests-env:latest . +# docker log +# docker push matrixdotorg/riotweb-ci-e2etests-env:latest +FROM node:10 +RUN apt-get update +RUN apt-get -y install build-essential python3-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev uuid-runtime +# dependencies for chrome (installed by puppeteer) +RUN apt-get -y install gconf-service libasound2 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget diff --git a/scripts/ci/end-to-end-tests.sh b/scripts/ci/end-to-end-tests.sh index d0b1a30ce2..0ec26df450 100644 --- a/scripts/ci/end-to-end-tests.sh +++ b/scripts/ci/end-to-end-tests.sh @@ -6,16 +6,38 @@ set -ev +upload_logs() { + echo "--- Uploading logs" + buildkite-agent artifact upload "logs/**/*;synapse/installations/consent/homeserver.log" +} + +handle_error() { + EXIT_CODE=$? + if [ $TESTS_STARTED -eq 1 ]; then + upload_logs + fi + exit $EXIT_CODE +} + +trap 'handle_error' ERR + RIOT_WEB_DIR=riot-web REACT_SDK_DIR=`pwd` + +echo "--- Building Riot" scripts/ci/build.sh # run end to end tests +echo "--- Fetching end-to-end tests from master" scripts/fetchdep.sh matrix-org matrix-react-end-to-end-tests master pushd matrix-react-end-to-end-tests ln -s $REACT_SDK_DIR/$RIOT_WEB_DIR riot/riot-web # PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh # CHROME_PATH=$(which google-chrome-stable) ./run.sh +echo "--- Install synapse & other dependencies" ./install.sh -./run.sh --travis +mkdir logs +echo "+++ Running end-to-end tests" +TESTS_STARTED=1 +./run.sh --no-sandbox --log-directory logs/ popd diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh index 6fb50e7cea..f82752bfc5 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -1,28 +1,40 @@ -#!/bin/sh +#!/bin/bash -org="$1" -repo="$2" +set -x + +deforg="$1" +defrepo="$2" defbranch="$3" [ -z "$defbranch" ] && defbranch="develop" -rm -r "$repo" || true +rm -r "$defrepo" || true clone() { - branch=$1 + org=$1 + repo=$2 + branch=$3 if [ -n "$branch" ] then - echo "Trying to use the branch $branch" - git clone https://github.com/$org/$repo.git $repo --branch "$branch" && exit 0 + echo "Trying to use $org/$repo#$branch" + git clone git://github.com/$org/$repo.git $repo --branch "$branch" && exit 0 fi } - # Try the PR author's branch in case it exists on the deps as well. -clone $BUILDKITE_BRANCH +# If BUILDKITE_BRANCH is set, it will contain either: +# * "branch" when the author's branch and target branch are in the same repo +# * "author:branch" when the author's branch is in their fork +# We can split on `:` into an array to check. +BUILDKITE_BRANCH_ARRAY=(${BUILDKITE_BRANCH//:/ }) +if [[ "${#BUILDKITE_BRANCH_ARRAY[@]}" == "1" ]]; then + clone $deforg $defrepo $BUILDKITE_BRANCH +elif [[ "${#BUILDKITE_BRANCH_ARRAY[@]}" == "2" ]]; then + clone ${BUILDKITE_BRANCH_ARRAY[0]} $defrepo ${BUILDKITE_BRANCH_ARRAY[1]} +fi # Try the target branch of the push or PR. -clone $BUILDKITE_PULL_REQUEST_BASE_BRANCH +clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH # Try the current branch from Jenkins. -clone `"echo $GIT_BRANCH" | sed -e 's/^origin\///'` +clone $deforg $defrepo `"echo $GIT_BRANCH" | sed -e 's/^origin\///'` # Use the default branch as the last resort. -clone $defbranch +clone $deforg $defrepo $defbranch diff --git a/src/Analytics.js b/src/Analytics.js index 44b85e4842..3e208ad6bd 100644 --- a/src/Analytics.js +++ b/src/Analytics.js @@ -84,6 +84,11 @@ const customVariables = { expl: _td('Whether or not you\'re using the Richtext mode of the Rich Text Editor'), example: 'off', }, + 'Breadcrumbs': { + id: 9, + expl: _td("Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)"), + example: 'disabled', + }, 'Homeserver URL': { id: 7, expl: _td('Your homeserver\'s URL'), @@ -201,6 +206,7 @@ class Analytics { trackEvent(category, action, name, value) { if (this.disabled) return; + this._paq.push(['setCustomUrl', getRedactedUrl()]); this._paq.push(['trackEvent', category, action, name, value]); } @@ -233,6 +239,11 @@ class Analytics { this._setVisitVariable('RTE: Uses Richtext Mode', state ? 'on' : 'off'); } + setBreadcrumbs(state) { + if (this.disabled) return; + this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled'); + } + showDetailsModal() { let rows = []; if (window.Piwik) { diff --git a/src/ContentMessages.js b/src/ContentMessages.js index a319118121..ee3e8f1390 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket 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. @@ -17,21 +18,27 @@ limitations under the License. 'use strict'; import Promise from 'bluebird'; -const extend = require('./extend'); -const dis = require('./dispatcher'); -const MatrixClientPeg = require('./MatrixClientPeg'); -const sdk = require('./index'); +import extend from './extend'; +import dis from './dispatcher'; +import MatrixClientPeg from './MatrixClientPeg'; +import sdk from './index'; import { _t } from './languageHandler'; -const Modal = require('./Modal'); - -const encrypt = require("browser-encrypt-attachment"); +import Modal from './Modal'; +import RoomViewStore from './stores/RoomViewStore'; +import encrypt from "browser-encrypt-attachment"; +import extractPngChunks from "png-chunks-extract"; // Polyfill for Canvas.toBlob API using Canvas.toDataURL -require("blueimp-canvas-to-blob"); +import "blueimp-canvas-to-blob"; const MAX_WIDTH = 800; const MAX_HEIGHT = 600; +// scraped out of a macOS hidpi (5660ppm) screenshot png +// 5669 px (x-axis) , 5669 px (y-axis) , per metre +const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01]; + +export class UploadCanceledError extends Error {} /** * Create a thumbnail for a image DOM element. @@ -91,27 +98,51 @@ function createThumbnail(element, inputWidth, inputHeight, mimeType) { /** * Load a file into a newly created image element. * - * @param {File} file The file to load in an image element. + * @param {File} imageFile The file to load in an image element. * @return {Promise} A promise that resolves with the html image element. */ -function loadImageElement(imageFile) { - const deferred = Promise.defer(); - +async function loadImageElement(imageFile) { // Load the file into an html element const img = document.createElement("img"); const objectUrl = URL.createObjectURL(imageFile); + const imgPromise = new Promise((resolve, reject) => { + img.onload = function() { + URL.revokeObjectURL(objectUrl); + resolve(img); + }; + img.onerror = function(e) { + reject(e); + }; + }); img.src = objectUrl; - // Once ready, create a thumbnail - img.onload = function() { - URL.revokeObjectURL(objectUrl); - deferred.resolve(img); - }; - img.onerror = function(e) { - deferred.reject(e); - }; + // check for hi-dpi PNGs and fudge display resolution as needed. + // this is mainly needed for macOS screencaps + let parsePromise; + if (imageFile.type === "image/png") { + // in practice macOS happens to order the chunks so they fall in + // the first 0x1000 bytes (thanks to a massive ICC header). + // Thus we could slice the file down to only sniff the first 0x1000 + // bytes (but this makes extractPngChunks choke on the corrupt file) + const headers = imageFile; //.slice(0, 0x1000); + parsePromise = readFileAsArrayBuffer(headers).then(arrayBuffer => { + const buffer = new Uint8Array(arrayBuffer); + const chunks = extractPngChunks(buffer); + for (const chunk of chunks) { + if (chunk.name === 'pHYs') { + if (chunk.data.byteLength !== PHYS_HIDPI.length) return; + const hidpi = chunk.data.every((val, i) => val === PHYS_HIDPI[i]); + return hidpi; + } + } + return false; + }); + } - return deferred.promise; + const [hidpi] = await Promise.all([parsePromise, imgPromise]); + const width = hidpi ? (img.width >> 1) : img.width; + const height = hidpi ? (img.height >> 1) : img.height; + return {width, height, img}; } /** @@ -119,7 +150,7 @@ function loadImageElement(imageFile) { * * @param {MatrixClient} matrixClient A matrixClient to upload the thumbnail with. * @param {String} roomId The ID of the room the image will be uploaded in. - * @param {File} The image to read and thumbnail. + * @param {File} imageFile The image to read and thumbnail. * @return {Promise} A promise that resolves with the attachment info. */ function infoForImageFile(matrixClient, roomId, imageFile) { @@ -129,8 +160,8 @@ function infoForImageFile(matrixClient, roomId, imageFile) { } let imageInfo; - return loadImageElement(imageFile).then(function(img) { - return createThumbnail(img, img.width, img.height, thumbnailType); + return loadImageElement(imageFile).then(function(r) { + return createThumbnail(r.img, r.width, r.height, thumbnailType); }).then(function(result) { imageInfo = result.info; return uploadFile(matrixClient, roomId, result.thumbnail); @@ -144,7 +175,7 @@ function infoForImageFile(matrixClient, roomId, imageFile) { /** * Load a file into a newly created video element. * - * @param {File} file The file to load in an video element. + * @param {File} videoFile The file to load in an video element. * @return {Promise} A promise that resolves with the video image element. */ function loadVideoElement(videoFile) { @@ -179,7 +210,7 @@ function loadVideoElement(videoFile) { * * @param {MatrixClient} matrixClient A matrixClient to upload the thumbnail with. * @param {String} roomId The ID of the room the video will be uploaded to. - * @param {File} The video to read and thumbnail. + * @param {File} videoFile The video to read and thumbnail. * @return {Promise} A promise that resolves with the attachment info. */ function infoForVideoFile(matrixClient, roomId, videoFile) { @@ -200,6 +231,7 @@ function infoForVideoFile(matrixClient, roomId, videoFile) { /** * Read the file as an ArrayBuffer. + * @param {File} file The file to read * @return {Promise} A promise that resolves with an ArrayBuffer when the file * is read. */ @@ -233,28 +265,40 @@ function uploadFile(matrixClient, roomId, file, progressHandler) { if (matrixClient.isRoomEncrypted(roomId)) { // If the room is encrypted then encrypt the file before uploading it. // First read the file into memory. - return readFileAsArrayBuffer(file).then(function(data) { + let canceled = false; + let uploadPromise; + let encryptInfo; + const prom = readFileAsArrayBuffer(file).then(function(data) { + if (canceled) throw new UploadCanceledError(); // Then encrypt the file. return encrypt.encryptAttachment(data); }).then(function(encryptResult) { + if (canceled) throw new UploadCanceledError(); // Record the information needed to decrypt the attachment. - const encryptInfo = encryptResult.info; + encryptInfo = encryptResult.info; // Pass the encrypted data as a Blob to the uploader. const blob = new Blob([encryptResult.data]); - return matrixClient.uploadContent(blob, { + uploadPromise = matrixClient.uploadContent(blob, { progressHandler: progressHandler, includeFilename: false, - }).then(function(url) { - // If the attachment is encrypted then bundle the URL along - // with the information needed to decrypt the attachment and - // add it under a file key. - encryptInfo.url = url; - if (file.type) { - encryptInfo.mimetype = file.type; - } - return {"file": encryptInfo}; }); + + return uploadPromise; + }).then(function(url) { + // If the attachment is encrypted then bundle the URL along + // with the information needed to decrypt the attachment and + // add it under a file key. + encryptInfo.url = url; + if (file.type) { + encryptInfo.mimetype = file.type; + } + return {"file": encryptInfo}; }); + prom.abort = () => { + canceled = true; + if (uploadPromise) MatrixClientPeg.get().cancelUpload(uploadPromise); + }; + return prom; } else { const basePromise = matrixClient.uploadContent(file, { progressHandler: progressHandler, @@ -269,11 +313,43 @@ function uploadFile(matrixClient, roomId, file, progressHandler) { } } - -class ContentMessages { +export default class ContentMessages { constructor() { this.inprogress = []; this.nextId = 0; + this._mediaConfig = null; + } + + static sharedInstance() { + if (global.mx_ContentMessages === undefined) { + global.mx_ContentMessages = new ContentMessages(); + } + return global.mx_ContentMessages; + } + + _isFileSizeAcceptable(file) { + if (this._mediaConfig !== null && + this._mediaConfig["m.upload.size"] !== undefined && + file.size > this._mediaConfig["m.upload.size"]) { + return false; + } + return true; + } + + _ensureMediaConfigFetched() { + if (this._mediaConfig !== null) return; + + console.log("[Media Config] Fetching"); + return MatrixClientPeg.get().getMediaConfig().then((config) => { + console.log("[Media Config] Fetched config:", config); + return config; + }).catch(() => { + // Media repo can't or won't report limits, so provide an empty object (no limits). + console.log("[Media Config] Could not fetch config, so not limiting uploads."); + return {}; + }).then((config) => { + this._mediaConfig = config; + }); } sendStickerContentToRoom(url, roomId, info, text, matrixClient) { @@ -283,7 +359,90 @@ class ContentMessages { }); } - sendContentToRoom(file, roomId, matrixClient) { + getUploadLimit() { + if (this._mediaConfig !== null && this._mediaConfig["m.upload.size"] !== undefined) { + return this._mediaConfig["m.upload.size"]; + } else { + return null; + } + } + + async sendContentListToRoom(files, roomId, matrixClient) { + if (matrixClient.isGuest()) { + dis.dispatch({action: 'require_registration'}); + return; + } + + const isQuoting = Boolean(RoomViewStore.getQuotingEvent()); + if (isQuoting) { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + const shouldUpload = await new Promise((resolve) => { + Modal.createTrackedDialog('Upload Reply Warning', '', QuestionDialog, { + title: _t('Replying With Files'), + description: ( +
{_t( + 'At this time it is not possible to reply with a file. ' + + 'Would you like to upload this file without replying?', + )}
+ ), + hasCancelButton: true, + button: _t("Continue"), + onFinished: (shouldUpload) => { + resolve(shouldUpload); + }, + }); + }); + if (!shouldUpload) return; + } + + await this._ensureMediaConfigFetched(); + + const tooBigFiles = []; + const okFiles = []; + + for (let i = 0; i < files.length; ++i) { + if (this._isFileSizeAcceptable(files[i])) { + okFiles.push(files[i]); + } else { + tooBigFiles.push(files[i]); + } + } + + if (tooBigFiles.length > 0) { + const UploadFailureDialog = sdk.getComponent("dialogs.UploadFailureDialog"); + const uploadFailureDialogPromise = new Promise((resolve) => { + Modal.createTrackedDialog('Upload Failure', '', UploadFailureDialog, { + badFiles: tooBigFiles, + totalFiles: files.length, + contentMessages: this, + onFinished: (shouldContinue) => { + resolve(shouldContinue); + }, + }); + }); + const shouldContinue = await uploadFailureDialogPromise; + if (!shouldContinue) return; + } + + const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); + 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 (!shouldContinue) break; + this._sendContentToRoom(file, roomId, matrixClient); + } + } + + _sendContentToRoom(file, roomId, matrixClient) { const content = { body: file.name || 'Attachment', info: { @@ -333,6 +492,9 @@ class ContentMessages { this.inprogress.push(upload); dis.dispatch({action: 'upload_started'}); + // Focus the composer view + dis.dispatch({action: 'focus_composer'}); + let error; function onProgress(ev) { @@ -357,9 +519,12 @@ class ContentMessages { }, function(err) { error = err; if (!upload.canceled) { - let desc = _t('The file \'%(fileName)s\' failed to upload', {fileName: upload.fileName}) + '.'; + let desc = _t("The file '%(fileName)s' failed to upload.", {fileName: upload.fileName}); if (err.http_status == 413) { - desc = _t('The file \'%(fileName)s\' exceeds this homeserver\'s size limit for uploads', {fileName: upload.fileName}); + desc = _t( + "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", + {fileName: upload.fileName}, + ); } const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Upload failed', '', ErrorDialog, { @@ -377,15 +542,22 @@ class ContentMessages { } } if (error) { + // 413: File was too big or upset the server in some way: + // clear the media size limit so we fetch it again next time + // we try to upload + if (error && error.http_status === 413) { + this._mediaConfig = null; + } dis.dispatch({action: 'upload_failed', upload, error}); } else { dis.dispatch({action: 'upload_finished', upload}); + dis.dispatch({action: 'message_sent'}); } }); } getCurrentUploads() { - return this.inprogress; + return this.inprogress.filter(u => !u.canceled); } cancelUpload(promise) { @@ -401,12 +573,7 @@ class ContentMessages { if (upload) { upload.canceled = true; MatrixClientPeg.get().cancelUpload(upload.promise); + dis.dispatch({action: 'upload_canceled', upload}); } } } - -if (global.mx_ContentMessage === undefined) { - global.mx_ContentMessage = new ContentMessages(); -} - -module.exports = global.mx_ContentMessage; diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index ea7eeba756..61c51d4a20 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -1,5 +1,6 @@ /* Copyright 2018 New Vector Ltd +Copyright 2019 Travis Ralston Licensed under the Apache License, Version 2.0 (the 'License'); you may not use this file except in compliance with the License. @@ -20,17 +21,19 @@ import IntegrationManager from './IntegrationManager'; import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; -const WIDGET_API_VERSION = '0.0.1'; // Current API version +const WIDGET_API_VERSION = '0.0.2'; // Current API version const SUPPORTED_WIDGET_API_VERSIONS = [ '0.0.1', + '0.0.2', ]; const INBOUND_API_NAME = 'fromWidget'; -// Listen for and handle incomming requests using the 'fromWidget' postMessage +// Listen for and handle incoming requests using the 'fromWidget' postMessage // API and initiate responses export default class FromWidgetPostMessageApi { constructor() { this.widgetMessagingEndpoints = []; + this.widgetListeners = {}; // {action: func[]} this.start = this.start.bind(this); this.stop = this.stop.bind(this); @@ -45,6 +48,32 @@ export default class FromWidgetPostMessageApi { window.removeEventListener('message', this.onPostMessage); } + /** + * Adds a listener for a given action + * @param {string} action The action to listen for. + * @param {Function} callbackFn A callback function to be called when the action is + * encountered. Called with two parameters: the interesting request information and + * the raw event received from the postMessage API. The raw event is meant to be used + * for sendResponse and similar functions. + */ + addListener(action, callbackFn) { + if (!this.widgetListeners[action]) this.widgetListeners[action] = []; + this.widgetListeners[action].push(callbackFn); + } + + /** + * Removes a listener for a given action. + * @param {string} action The action that was subscribed to. + * @param {Function} callbackFn The original callback function that was used to subscribe + * to updates. + */ + removeListener(action, callbackFn) { + if (!this.widgetListeners[action]) return; + + const idx = this.widgetListeners[action].indexOf(callbackFn); + if (idx !== -1) this.widgetListeners[action].splice(idx, 1); + } + /** * Register a widget endpoint for trusted postMessage communication * @param {string} widgetId Unique widget identifier @@ -87,10 +116,8 @@ export default class FromWidgetPostMessageApi { const origin = u.protocol + '//' + u.host; if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) { const length = this.widgetMessagingEndpoints.length; - this.widgetMessagingEndpoints = this.widgetMessagingEndpoints. - filter(function(endpoint) { - return (endpoint.widgetId != widgetId || endpoint.endpointUrl != origin); - }); + this.widgetMessagingEndpoints = this.widgetMessagingEndpoints + .filter((endpoint) => endpoint.widgetId !== widgetId || endpoint.endpointUrl !== origin); return (length > this.widgetMessagingEndpoints.length); } return false; @@ -117,6 +144,13 @@ export default class FromWidgetPostMessageApi { return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise } + // Call any listeners we have registered + if (this.widgetListeners[event.data.action]) { + for (const fn of this.widgetListeners[event.data.action]) { + fn(event.data, event); + } + } + // Although the requestId is required, we don't use it. We'll be nice and process the message // if the property is missing, but with a warning for widget developers. if (!event.data.requestId) { @@ -164,6 +198,8 @@ export default class FromWidgetPostMessageApi { if (ActiveWidgetStore.widgetHasCapability(widgetId, 'm.always_on_screen')) { ActiveWidgetStore.setWidgetPersistence(widgetId, val); } + } else if (action === 'get_openid') { + // Handled by caller } else { console.warn('Widget postMessage event unhandled'); this.sendError(event, {message: 'The postMessage was unhandled'}); diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 3315e86e71..d9d8bac93b 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -516,7 +516,11 @@ export function bodyToHtml(content, highlights, opts={}) { contentBodyTrimmed = contentBodyTrimmed.replace(ZWJ_REGEX, ''); const match = EMOJI_REGEX.exec(contentBodyTrimmed); - emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; + emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length + // Prevent user pills expanding for users with only emoji in + // their username + && (content.formatted_body == undefined + || !content.formatted_body.includes("https://matrix.to/")); } const className = classNames({ diff --git a/src/Lifecycle.js b/src/Lifecycle.js index f7579cf3c0..527394da4d 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -31,7 +31,8 @@ import Modal from './Modal'; import sdk from './index'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; import PlatformPeg from "./PlatformPeg"; -import {sendLoginRequest} from "./Login"; +import { sendLoginRequest } from "./Login"; +import * as StorageManager from './utils/StorageManager'; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries @@ -102,9 +103,14 @@ export async function loadSession(opts) { return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); } - // fall back to login screen + // fall back to welcome screen return false; } catch (e) { + if (e instanceof AbortLoginAndRebuildStorage) { + // If we're aborting login because of a storage inconsistency, we don't + // need to show the general failure dialog. Instead, just go back to welcome. + return false; + } return _handleLoadSessionFailure(e); } } @@ -197,9 +203,6 @@ export function handleInvalidStoreError(e) { function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { console.log(`Doing guest login on ${hsUrl}`); - // TODO: we should probably de-duplicate this and Login.loginAsGuest. - // Not really sure where the right home for it is. - // create a temporary MatrixClient to do the login const client = Matrix.createClient({ baseUrl: hsUrl, @@ -278,7 +281,7 @@ async function _restoreFromLocalStorage() { } function _handleLoadSessionFailure(e) { - console.log("Unable to load session", e); + console.error("Unable to load session", e); const def = Promise.defer(); const SessionRestoreErrorDialog = @@ -353,6 +356,22 @@ async function _doSetLoggedIn(credentials, clearStorage) { await _clearStorage(); } + const results = await StorageManager.checkConsistency(); + // If there's an inconsistency between account data in local storage and the + // crypto store, we'll be generally confused when handling encrypted data. + // Show a modal recommending a full reset of storage. + if (results.dataInLocalStorage && !results.dataInCryptoStore) { + const signOut = await _showStorageEvictedDialog(); + if (signOut) { + await _clearStorage(); + // This error feels a bit clunky, but we want to make sure we don't go any + // further and instead head back to sign in. + throw new AbortLoginAndRebuildStorage( + "Aborting login in progress because of storage inconsistency", + ); + } + } + Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl, credentials.identityServerUrl); if (localStorage) { @@ -383,6 +402,19 @@ async function _doSetLoggedIn(credentials, clearStorage) { return MatrixClientPeg.get(); } +function _showStorageEvictedDialog() { + const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog'); + return new Promise(resolve => { + Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, { + onFinished: resolve, + }); + }); +} + +// Note: Babel 6 requires the `transform-builtin-extend` plugin for this to satisfy +// `instanceof`. Babel 7 supports this natively in their class handling. +class AbortLoginAndRebuildStorage extends Error { } + function _persistCredentialsToLocalStorage(credentials) { localStorage.setItem("mx_hs_url", credentials.homeserverUrl); localStorage.setItem("mx_is_url", credentials.identityServerUrl); diff --git a/src/Login.js b/src/Login.js index 893ec42097..c31a9308a8 100644 --- a/src/Login.js +++ b/src/Login.js @@ -81,26 +81,6 @@ export default class Login { return flowStep ? flowStep.type : null; } - loginAsGuest() { - const client = this._createTemporaryClient(); - return client.registerGuest({ - body: { - initial_device_display_name: this._defaultDeviceDisplayName, - }, - }).then((creds) => { - return { - userId: creds.user_id, - deviceId: creds.device_id, - accessToken: creds.access_token, - homeserverUrl: this._hsUrl, - identityServerUrl: this._isUrl, - guest: true, - }; - }, (error) => { - throw error; - }); - } - loginViaPassword(username, phoneCountry, phoneNumber, pass) { const self = this; diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 1cf29c3e82..763eddbd5d 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -31,6 +31,7 @@ import {phasedRollOutExpiredForUser} from "./PhasedRollOut"; import Modal from './Modal'; import {verificationMethods} from 'matrix-js-sdk/lib/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; +import * as StorageManager from './utils/StorageManager'; interface MatrixClientCreds { homeserverUrl: string, @@ -103,7 +104,7 @@ class MatrixClientPeg { } catch (err) { if (dbType === 'indexeddb') { console.error('Error starting matrixclient store - falling back to memory store', err); - this.matrixClient.store = new Matrix.MatrixInMemoryStore({ + this.matrixClient.store = new Matrix.MemoryStore({ localStorage: global.localStorage, }); } else { @@ -113,6 +114,8 @@ class MatrixClientPeg { } } + StorageManager.trackStores(this.matrixClient); + // try to initialise e2e on the new client try { // check that we have a version of the js-sdk which includes initCrypto @@ -120,7 +123,7 @@ class MatrixClientPeg { await this.matrixClient.initCrypto(); } } catch (e) { - if (e.name === 'InvalidCryptoStoreError') { + if (e && e.name === 'InvalidCryptoStoreError') { // The js-sdk found a crypto DB too new for it to use const CryptoStoreTooNewDialog = sdk.getComponent("views.dialogs.CryptoStoreTooNewDialog"); @@ -130,7 +133,7 @@ class MatrixClientPeg { } // this can happen for a number of reasons, the most likely being // that the olm library was missing. It's not fatal. - console.warn("Unable to initialise e2e: " + e); + console.warn("Unable to initialise e2e", e); } const opts = utils.deepCopy(this.opts); @@ -171,7 +174,7 @@ class MatrixClientPeg { return matches[1]; } - _createClient(creds: MatrixClientCreds, useIndexedDb) { + _createClient(creds: MatrixClientCreds) { const opts = { baseUrl: creds.homeserverUrl, idBaseUrl: creds.identityServerUrl, @@ -183,7 +186,7 @@ class MatrixClientPeg { verificationMethods: [verificationMethods.SAS] }; - this.matrixClient = createMatrixClient(opts, useIndexedDb); + this.matrixClient = createMatrixClient(opts); // we're going to add eventlisteners for each matrix event tile, so the // potential number of event listeners is quite high. diff --git a/src/Modal.js b/src/Modal.js index 4d90e313ce..a114ad2d3c 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -75,10 +75,9 @@ const AsyncWrapper = React.createClass({ }, render: function() { - const {loader, ...otherProps} = this.props; if (this.state.component) { const Component = this.state.component; - return ; + return ; } else if (this.state.error) { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); @@ -124,6 +123,10 @@ class ModalManager { this.closeAll = this.closeAll.bind(this); } + hasDialogs() { + return this._priorityModal || this._staticModal || this._modals.length > 0; + } + getOrCreateContainer() { let container = document.getElementById(DIALOG_CONTAINER_ID); @@ -154,7 +157,7 @@ class ModalManager { } createDialog(Element, ...rest) { - return this.createDialogAsync(new Promise(resolve => resolve(Element)), ...rest); + return this.createDialogAsync(Promise.resolve(Element), ...rest); } createTrackedDialogAsync(analyticsAction, analyticsInfo, ...rest) { @@ -189,36 +192,35 @@ class ModalManager { * also be removed from the stack. This is not compatible * with being a priority modal. Only one modal can be * static at a time. + * @returns {object} Object with 'close' parameter being a function that will close the dialog */ createDialogAsync(prom, props, className, isPriorityModal, isStaticModal) { - const self = this; const modal = {}; // never call this from onFinished() otherwise it will loop // - // nb explicit function() rather than arrow function, to get `arguments` - const closeDialog = function() { - if (props && props.onFinished) props.onFinished.apply(null, arguments); - const i = self._modals.indexOf(modal); + const closeDialog = (...args) => { + if (props && props.onFinished) props.onFinished.apply(null, args); + const i = this._modals.indexOf(modal); if (i >= 0) { - self._modals.splice(i, 1); + this._modals.splice(i, 1); } - if (self._priorityModal === modal) { - self._priorityModal = null; + if (this._priorityModal === modal) { + this._priorityModal = null; // XXX: This is destructive - self._modals = []; + this._modals = []; } - if (self._staticModal === modal) { - self._staticModal = null; + if (this._staticModal === modal) { + this._staticModal = null; // XXX: This is destructive - self._modals = []; + this._modals = []; } - self._reRender(); + this._reRender(); }; // don't attempt to reuse the same AsyncWrapper for different dialogs, diff --git a/src/Notifier.js b/src/Notifier.js index 80e8be1084..6a4f9827f7 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -220,7 +220,17 @@ const Notifier = { } }, - isToolbarHidden: function() { + shouldShowToolbar: function() { + const client = MatrixClientPeg.get(); + if (!client) { + return false; + } + const isGuest = client.isGuest(); + return !isGuest && this.supportsDesktopNotifications() && + !this.isEnabled() && !this._isToolbarHidden(); + }, + + _isToolbarHidden: function() { // Check localStorage for any such meta data if (global.localStorage) { return global.localStorage.getItem("notifications_hidden") === "true"; diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 3547b9195f..b808b935a6 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -65,6 +65,24 @@ export function showRoomInviteDialog(roomId) { }); } +/** + * Checks if the given MatrixEvent is a valid 3rd party user invite. + * @param {MatrixEvent} event The event to check + * @returns {boolean} True if valid, false otherwise + */ +export function isValid3pidInvite(event) { + if (!event || event.getType() !== "m.room.third_party_invite") return false; + + // any events without these keys are not valid 3pid invites, so we ignore them + const requiredKeys = ['key_validity_url', 'public_key', 'display_name']; + for (let i = 0; i < requiredKeys.length; ++i) { + if (!event.getContent()[requiredKeys[i]]) return false; + } + + // Valid enough by our standards + return true; +} + function _onStartChatFinished(shouldInvite, addrs) { if (!shouldInvite) return; diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index 77fb5efb76..39384b5bea 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -23,6 +23,8 @@ export const ALL_MESSAGES = 'all_messages'; export const MENTIONS_ONLY = 'mentions_only'; 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]; @@ -107,6 +109,28 @@ export function setRoomNotifsState(roomId, newState) { } } +export function getUnreadNotificationCount(room, type=null) { + let notificationCount = room.getUnreadNotificationCount(type); + + // Check notification counts in the old room just in case there's some lost + // there. We only go one level down to avoid performance issues, and theory + // is that 1st generation rooms will have already been read by the 3rd generation. + const createEvent = room.currentState.getStateEvents("m.room.create", ""); + if (createEvent && createEvent.getContent()['predecessor']) { + const oldRoomId = createEvent.getContent()['predecessor']['room_id']; + const oldRoom = MatrixClientPeg.get().getRoom(oldRoomId); + if (oldRoom) { + // We only ever care if there's highlights in the old room. No point in + // notifying the user for unread messages because they would have extreme + // difficulty changing their notification preferences away from "All Messages" + // and "Noisy". + notificationCount += oldRoom.getUnreadNotificationCount("highlight"); + } + } + + return notificationCount; +} + function setRoomNotifsStateMuted(roomId) { const cli = MatrixClientPeg.get(); const promises = []; @@ -204,4 +228,3 @@ function isRuleForRoom(roomId, rule) { function isMuteRule(rule) { return (rule.actions.length === 1 && rule.actions[0] === 'dont_notify'); } - diff --git a/src/SdkConfig.js b/src/SdkConfig.js index 78dd050a1e..eb18dad453 100644 --- a/src/SdkConfig.js +++ b/src/SdkConfig.js @@ -41,6 +41,12 @@ class SdkConfig { static unset() { global.mxReactSdkConfig = undefined; } + + static add(cfg) { + const liveConfig = SdkConfig.get(); + const newConfig = Object.assign({}, liveConfig, cfg); + SdkConfig.put(newConfig); + } } module.exports = SdkConfig; diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 711346c4a7..f72ba1e005 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -28,6 +28,8 @@ import {MATRIXTO_URL_PATTERN} from "./linkify-matrix"; import * as querystring from "querystring"; import MultiInviter from './utils/MultiInviter'; import { linkifyAndSanitizeHtml } from './HtmlUtils'; +import QuestionDialog from "./components/views/dialogs/QuestionDialog"; +import WidgetUtils from "./utils/WidgetUtils"; class Command { constructor({name, args='', description, runFn, hideCompletionAfterSpace=false}) { @@ -105,7 +107,72 @@ export const CommandMap = { description: _td('Upgrades a room to a new version'), runFn: function(roomId, args) { if (args) { - return success(MatrixClientPeg.get().upgradeRoom(roomId, args)); + const room = MatrixClientPeg.get().getRoom(roomId); + Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', + QuestionDialog, { + title: _t('Room upgrade confirmation'), + description: ( +
+

{_t("Upgrading a room can be destructive and isn't always necessary.")}

+

+ {_t( + "Room upgrades are usually recommended when a room version is considered " + + "unstable. Unstable room versions might have bugs, missing features, or " + + "security vulnerabilities.", + {}, { + "i": (sub) => {sub}, + }, + )} +

+

+ {_t( + "Room upgrades usually only affect server-side processing of the " + + "room. If you're having problems with your Riot client, please file an issue " + + "with .", + {}, { + "i": (sub) => {sub}, + "issueLink": () => { + return + https://github.com/vector-im/riot-web/issues/new/choose + ; + }, + }, + )} +

+

+ {_t( + "Warning: Upgrading a room will not automatically migrate room " + + "members to the new version of the room. We'll post a link to the new room " + + "in the old version of the room - room members will have to click this link to " + + "join the new room.", + {}, { + "b": (sub) => {sub}, + "i": (sub) => {sub}, + }, + )} +

+

+ {_t( + "Please confirm that you'd like to go forward with upgrading this room " + + "from to .", + {}, + { + oldVersion: () => {room ? room.getVersion() : "1"}, + newVersion: () => {args}, + }, + )} +

+
+ ), + button: _t("Upgrade"), + onFinished: (confirm) => { + if (!confirm) return; + + MatrixClientPeg.get().upgradeRoom(roomId, args); + }, + }); + return success(); } return reject(this.getUsage()); }, @@ -365,7 +432,7 @@ export const CommandMap = { if (!targetRoomId) targetRoomId = roomId; return success( - cli.leave(targetRoomId).then(function() { + cli.leaveRoomChain(targetRoomId).then(function() { dis.dispatch({action: 'view_next_room'}); }), ); @@ -540,6 +607,26 @@ export const CommandMap = { }, }), + addwidget: new Command({ + name: 'addwidget', + args: '', + description: _td('Adds a custom widget by URL to the room'), + runFn: function(roomId, args) { + if (!args || (!args.startsWith("https://") && !args.startsWith("http://"))) { + return reject(_t("Please supply a https:// or http:// widget URL")); + } + if (WidgetUtils.canUserModifyWidgets(roomId)) { + const userId = MatrixClientPeg.get().getUserId(); + const nowMs = (new Date()).getTime(); + const widgetId = encodeURIComponent(`${roomId}_${userId}_${nowMs}`); + return success(WidgetUtils.setRoomWidget( + roomId, widgetId, "m.custom", args, "Custom Widget", {})); + } else { + return reject(_t("You cannot modify widgets in this room.")); + } + }, + }), + // Verify a user, device, and pubkey tuple verify: new Command({ name: 'verify', diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 030c346ccc..a700fe2a3c 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -17,6 +17,7 @@ import MatrixClientPeg from './MatrixClientPeg'; import CallHandler from './CallHandler'; import { _t } from './languageHandler'; import * as Roles from './Roles'; +import {isValid3pidInvite} from "./RoomInvite"; function textForMemberEvent(ev) { // XXX: SYJS-16 "sender is sometimes null for join messages" @@ -366,6 +367,15 @@ function textForCallInviteEvent(event) { function textForThreePidInviteEvent(event) { const senderName = event.sender ? event.sender.name : event.getSender(); + + if (!isValid3pidInvite(event)) { + const targetDisplayName = event.getPrevContent().display_name || _t("Someone"); + return _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', { + senderName, + targetDisplayName, + }); + } + return _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', { senderName, targetDisplayName: event.getContent().display_name, diff --git a/src/Velociraptor.js b/src/Velociraptor.js index ad51f66ae3..d2cae5c2a7 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -1,7 +1,7 @@ const React = require('react'); const ReactDom = require('react-dom'); import PropTypes from 'prop-types'; -const Velocity = require('velocity-vector'); +const Velocity = require('velocity-animate'); /** * The Velociraptor contains components and animates transitions with velocity. diff --git a/src/VelocityBounce.js b/src/VelocityBounce.js index b9513249b8..db216f81fb 100644 --- a/src/VelocityBounce.js +++ b/src/VelocityBounce.js @@ -1,4 +1,4 @@ -const Velocity = require('velocity-vector'); +const Velocity = require('velocity-animate'); // courtesy of https://github.com/julianshapiro/velocity/issues/283 // We only use easeOutBounce (easeInBounce is just sort of nonsensical) diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js index 5b722df65f..1d8e1b9cd3 100644 --- a/src/WidgetMessaging.js +++ b/src/WidgetMessaging.js @@ -1,5 +1,6 @@ /* Copyright 2017 New Vector Ltd +Copyright 2019 Travis Ralston Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,6 +22,11 @@ limitations under the License. import FromWidgetPostMessageApi from './FromWidgetPostMessageApi'; import ToWidgetPostMessageApi from './ToWidgetPostMessageApi'; +import Modal from "./Modal"; +import MatrixClientPeg from "./MatrixClientPeg"; +import SettingsStore from "./settings/SettingsStore"; +import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog"; +import WidgetUtils from "./utils/WidgetUtils"; if (!global.mxFromWidgetMessaging) { global.mxFromWidgetMessaging = new FromWidgetPostMessageApi(); @@ -34,12 +40,14 @@ if (!global.mxToWidgetMessaging) { const OUTBOUND_API_NAME = 'toWidget'; export default class WidgetMessaging { - constructor(widgetId, widgetUrl, target) { + constructor(widgetId, widgetUrl, isUserWidget, target) { this.widgetId = widgetId; this.widgetUrl = widgetUrl; + this.isUserWidget = isUserWidget; this.target = target; this.fromWidget = global.mxFromWidgetMessaging; this.toWidget = global.mxToWidgetMessaging; + this._onOpenIdRequest = this._onOpenIdRequest.bind(this); this.start(); } @@ -109,9 +117,57 @@ export default class WidgetMessaging { start() { this.fromWidget.addEndpoint(this.widgetId, this.widgetUrl); + this.fromWidget.addListener("get_openid", this._onOpenIdRequest); } stop() { this.fromWidget.removeEndpoint(this.widgetId, this.widgetUrl); + this.fromWidget.removeListener("get_openid", this._onOpenIdRequest); + } + + async _onOpenIdRequest(ev, rawEv) { + if (ev.widgetId !== this.widgetId) return; // not interesting + + const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.widgetUrl, this.isUserWidget); + + const settings = SettingsStore.getValue("widgetOpenIDPermissions"); + if (settings.deny && settings.deny.includes(widgetSecurityKey)) { + this.fromWidget.sendResponse(rawEv, {state: "blocked"}); + return; + } + if (settings.allow && settings.allow.includes(widgetSecurityKey)) { + const responseBody = {state: "allowed"}; + const credentials = await MatrixClientPeg.get().getOpenIdToken(); + Object.assign(responseBody, credentials); + this.fromWidget.sendResponse(rawEv, responseBody); + return; + } + + // Confirm that we received the request + this.fromWidget.sendResponse(rawEv, {state: "request"}); + + // Actually ask for permission to send the user's data + Modal.createTrackedDialog("OpenID widget permissions", '', + WidgetOpenIDPermissionsDialog, { + widgetUrl: this.widgetUrl, + widgetId: this.widgetId, + isUserWidget: this.isUserWidget, + + onFinished: async (confirm) => { + const responseBody = {success: confirm}; + if (confirm) { + const credentials = await MatrixClientPeg.get().getOpenIdToken(); + Object.assign(responseBody, credentials); + } + this.messageToWidget({ + api: OUTBOUND_API_NAME, + action: "openid_credentials", + data: responseBody, + }).catch((error) => { + console.error("Failed to send OpenID credentials: ", error); + }); + }, + }, + ); } } diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 483506557f..f118a06b9e 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -56,7 +56,7 @@ export default class RoomProvider extends AutocompleteProvider { const {command, range} = this.getCurrentCommand(query, selection, force); if (command) { // the only reason we need to do this is because Fuse only matches on properties - this.matcher.setObjects(client.getRooms().filter( + let matcherObjects = client.getRooms().filter( (room) => !!room && !!getDisplayAliasForRoom(room), ).map((room) => { return { @@ -64,7 +64,21 @@ export default class RoomProvider extends AutocompleteProvider { name: room.name, displayedAlias: getDisplayAliasForRoom(room), }; - })); + }); + + // Filter out any matches where the user will have also autocompleted new rooms + matcherObjects = matcherObjects.filter((r) => { + const tombstone = r.room.currentState.getStateEvents("m.room.tombstone", ""); + if (tombstone && tombstone.getContent() && tombstone.getContent()['replacement_room']) { + const hasReplacementRoom = matcherObjects.some( + (r2) => r2.room.roomId === tombstone.getContent()['replacement_room'], + ); + return !hasReplacementRoom; + } + return true; + }); + + this.matcher.setObjects(matcherObjects); const matchedString = command[0]; completions = this.matcher.match(matchedString); completions = _sortBy(completions, [ diff --git a/src/components/structures/AutoHideScrollbar.js b/src/components/structures/AutoHideScrollbar.js index 0f93f20407..3f27f51f18 100644 --- a/src/components/structures/AutoHideScrollbar.js +++ b/src/components/structures/AutoHideScrollbar.js @@ -121,8 +121,10 @@ export default class AutoHideScrollbar extends React.Component { render() { return (
{ this.props.children } diff --git a/src/components/structures/BottomLeftMenu.js b/src/components/structures/BottomLeftMenu.js deleted file mode 100644 index 2f48bd0299..0000000000 --- a/src/components/structures/BottomLeftMenu.js +++ /dev/null @@ -1,197 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations 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 React from 'react'; -import ReactDOM from 'react-dom'; -import sdk from '../../index'; -import dis from '../../dispatcher'; -import Velocity from 'velocity-vector'; -import 'velocity-vector/velocity.ui'; -import SettingsStore from '../../settings/SettingsStore'; - -const CALLOUT_ANIM_DURATION = 1000; - -module.exports = React.createClass({ - displayName: 'BottomLeftMenu', - - propTypes: { - collapsed: React.PropTypes.bool.isRequired, - }, - - getInitialState: function() { - return ({ - directoryHover: false, - roomsHover: false, - homeHover: false, - peopleHover: false, - settingsHover: false, - }); - }, - - componentWillMount: function() { - this._dispatcherRef = dis.register(this.onAction); - this._peopleButton = null; - this._directoryButton = null; - this._createRoomButton = null; - this._lastCallouts = {}; - }, - - componentWillUnmount: function() { - dis.unregister(this._dispatcherRef); - }, - - // Room events - onDirectoryClick: function() { - dis.dispatch({ action: 'view_room_directory' }); - }, - - onDirectoryMouseEnter: function() { - this.setState({ directoryHover: true }); - }, - - onDirectoryMouseLeave: function() { - this.setState({ directoryHover: false }); - }, - - onRoomsClick: function() { - dis.dispatch({ action: 'view_create_room' }); - }, - - onRoomsMouseEnter: function() { - this.setState({ roomsHover: true }); - }, - - onRoomsMouseLeave: function() { - this.setState({ roomsHover: false }); - }, - - // Home button events - onHomeClick: function() { - dis.dispatch({ action: 'view_home_page' }); - }, - - onHomeMouseEnter: function() { - this.setState({ homeHover: true }); - }, - - onHomeMouseLeave: function() { - this.setState({ homeHover: false }); - }, - - // People events - onPeopleClick: function() { - dis.dispatch({ action: 'view_create_chat' }); - }, - - onPeopleMouseEnter: function() { - this.setState({ peopleHover: true }); - }, - - onPeopleMouseLeave: function() { - this.setState({ peopleHover: false }); - }, - - // Settings events - onSettingsClick: function() { - dis.dispatch({ action: 'view_user_settings' }); - }, - - onSettingsMouseEnter: function() { - this.setState({ settingsHover: true }); - }, - - onSettingsMouseLeave: function() { - this.setState({ settingsHover: false }); - }, - - onAction: function(payload) { - let calloutElement; - switch (payload.action) { - // Incoming instruction: dance! - case 'callout_start_chat': - calloutElement = this._peopleButton; - break; - case 'callout_room_directory': - calloutElement = this._directoryButton; - break; - case 'callout_create_room': - calloutElement = this._createRoomButton; - break; - } - if (calloutElement) { - const lastCallout = this._lastCallouts[payload.action]; - const now = Date.now(); - if (lastCallout == undefined || lastCallout < now - CALLOUT_ANIM_DURATION) { - this._lastCallouts[payload.action] = now; - Velocity(ReactDOM.findDOMNode(calloutElement), "callout.bounce", CALLOUT_ANIM_DURATION); - } - } - }, - - // Get the label/tooltip to show - getLabel: function(label, show) { - if (show) { - const Tooltip = sdk.getComponent("elements.Tooltip"); - return ; - } - }, - - _collectPeopleButton: function(e) { - this._peopleButton = e; - }, - - _collectDirectoryButton: function(e) { - this._directoryButton = e; - }, - - _collectCreateRoomButton: function(e) { - this._createRoomButton = e; - }, - - render: function() { - const HomeButton = sdk.getComponent('elements.HomeButton'); - const StartChatButton = sdk.getComponent('elements.StartChatButton'); - const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton'); - const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton'); - const SettingsButton = sdk.getComponent('elements.SettingsButton'); - const GroupsButton = sdk.getComponent('elements.GroupsButton'); - - const groupsButton = !SettingsStore.getValue("TagPanel.enableTagPanel") ? - : null; - - return ( -
-
- -
- -
-
- -
-
- -
- { groupsButton } - - - -
-
- ); - }, -}); diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js index d551a6fe27..345eae2b18 100644 --- a/src/components/structures/ContextualMenu.js +++ b/src/components/structures/ContextualMenu.js @@ -56,6 +56,7 @@ export default class ContextualMenu extends React.Component { menuPaddingRight: PropTypes.number, menuPaddingBottom: PropTypes.number, menuPaddingLeft: PropTypes.number, + zIndex: PropTypes.number, // If true, insert an invisible screen-sized element behind the // menu that when clicked will close it. @@ -215,16 +216,22 @@ export default class ContextualMenu extends React.Component { menuStyle["paddingRight"] = props.menuPaddingRight; } + const wrapperStyle = {}; + if (!isNaN(Number(props.zIndex))) { + menuStyle["zIndex"] = props.zIndex + 1; + wrapperStyle["zIndex"] = props.zIndex; + } + const ElementClass = props.elementClass; // FIXME: If a menu uses getDefaultProps it clobbers the onFinished // property set here so you can't close the menu from a button click! - return
+ return
{ chevron }
- { props.hasBackground &&
}
; diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 927449750c..e35a39a107 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -123,6 +123,7 @@ const FilePanel = React.createClass({ timelineSet={this.state.timelineSet} showUrlPreview = {false} tileShape="file_grid" + resizeNotifier={this.props.resizeNotifier} empty={_t('There are no visible files in this room')} /> ); diff --git a/src/components/structures/GenericErrorPage.js b/src/components/structures/GenericErrorPage.js new file mode 100644 index 0000000000..f28f66064c --- /dev/null +++ b/src/components/structures/GenericErrorPage.js @@ -0,0 +1,38 @@ +/* +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. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import {_t} from "../../languageHandler"; + +export default class GenericErrorPage extends React.PureComponent { + static propTypes = { + message: PropTypes.string.isRequired, + }; + + render() { + return
+
+

{_t("Error loading Riot")}

+

{this.props.message}

+

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

+
+
; + } +} diff --git a/src/components/structures/IndicatorScrollbar.js b/src/components/structures/IndicatorScrollbar.js index e1516d1f64..a615717104 100644 --- a/src/components/structures/IndicatorScrollbar.js +++ b/src/components/structures/IndicatorScrollbar.js @@ -15,9 +15,22 @@ limitations under the License. */ import React from "react"; +import PropTypes from "prop-types"; import AutoHideScrollbar from "./AutoHideScrollbar"; export default class IndicatorScrollbar extends React.Component { + static PropTypes = { + // If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator + // and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning + // by the parent element. + trackHorizontalOverflow: PropTypes.bool, + + // If true, when the user tries to use their mouse wheel in the component it will + // scroll horizontally rather than vertically. This should only be used on components + // with no vertical scroll opportunity. + verticalScrollsHorizontally: PropTypes.bool, + }; + constructor(props) { super(props); this._collectScroller = this._collectScroller.bind(this); @@ -25,6 +38,18 @@ export default class IndicatorScrollbar extends React.Component { this.checkOverflow = this.checkOverflow.bind(this); this._scrollElement = null; this._autoHideScrollbar = null; + + this.state = { + leftIndicatorOffset: 0, + rightIndicatorOffset: 0, + }; + } + + moveToOrigin() { + if (!this._scrollElement) return; + + this._scrollElement.scrollLeft = 0; + this._scrollElement.scrollTop = 0; } _collectScroller(scroller) { @@ -43,6 +68,10 @@ export default class IndicatorScrollbar extends React.Component { const hasTopOverflow = this._scrollElement.scrollTop > 0; const hasBottomOverflow = this._scrollElement.scrollHeight > (this._scrollElement.scrollTop + this._scrollElement.clientHeight); + const hasLeftOverflow = this._scrollElement.scrollLeft > 0; + const hasRightOverflow = this._scrollElement.scrollWidth > + (this._scrollElement.scrollLeft + this._scrollElement.clientWidth); + if (hasTopOverflow) { this._scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow"); } else { @@ -53,10 +82,30 @@ export default class IndicatorScrollbar extends React.Component { } else { this._scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow"); } + if (hasLeftOverflow) { + this._scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow"); + } else { + this._scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow"); + } + if (hasRightOverflow) { + this._scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow"); + } else { + this._scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow"); + } if (this._autoHideScrollbar) { this._autoHideScrollbar.checkOverflow(); } + + if (this.props.trackHorizontalOverflow) { + this.setState({ + // Offset from absolute position of the container + leftIndicatorOffset: hasLeftOverflow ? `${this._scrollElement.scrollLeft}px` : '0', + + // Negative because we're coming from the right + rightIndicatorOffset: hasRightOverflow ? `-${this._scrollElement.scrollLeft}px` : '0', + }); + } } getScrollTop() { @@ -69,13 +118,41 @@ export default class IndicatorScrollbar extends React.Component { } } + onMouseWheel = (e) => { + if (this.props.verticalScrollsHorizontally && this._scrollElement) { + // xyThreshold is the amount of horizontal motion required for the component to + // ignore the vertical delta in a scroll. Used to stop trackpads from acting in + // strange ways. Should be positive. + const xyThreshold = 0; + + // yRetention is the factor multiplied by the vertical delta to try and reduce + // the harshness of the scroll behaviour. Should be a value between 0 and 1. + const yRetention = 1.0; + + if (Math.abs(e.deltaX) < xyThreshold) { + // noinspection JSSuspiciousNameCombination + this._scrollElement.scrollLeft += e.deltaY * yRetention; + } + } + }; + render() { + const leftIndicatorStyle = {left: this.state.leftIndicatorOffset}; + const rightIndicatorStyle = {right: this.state.rightIndicatorOffset}; + const leftOverflowIndicator = this.props.trackHorizontalOverflow + ?
: null; + const rightOverflowIndicator = this.props.trackHorizontalOverflow + ?
: null; + return ( + { leftOverflowIndicator } { this.props.children } + { rightOverflowIndicator } ); } } diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index 21438c597c..7a16db1c6a 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -27,6 +27,7 @@ import VectorConferenceHandler from '../../VectorConferenceHandler'; import TagPanelButtons from './TagPanelButtons'; import SettingsStore from '../../settings/SettingsStore'; import {_t} from "../../languageHandler"; +import Analytics from "../../Analytics"; const LeftPanel = React.createClass({ @@ -45,11 +46,23 @@ const LeftPanel = React.createClass({ getInitialState: function() { return { searchFilter: '', + breadcrumbs: false, }; }, componentWillMount: function() { this.focusedElement = null; + + this._settingWatchRef = SettingsStore.watchSetting( + "feature_room_breadcrumbs", null, this._onBreadcrumbsChanged); + + const useBreadcrumbs = SettingsStore.isFeatureEnabled("feature_room_breadcrumbs"); + Analytics.setBreadcrumbs(useBreadcrumbs); + this.setState({breadcrumbs: useBreadcrumbs}); + }, + + componentWillUnmount: function() { + SettingsStore.unwatchSetting(this._settingWatchRef); }, shouldComponentUpdate: function(nextProps, nextState) { @@ -73,6 +86,22 @@ const LeftPanel = React.createClass({ return false; }, + componentDidUpdate(prevProps, prevState) { + if (prevState.breadcrumbs !== this.state.breadcrumbs) { + Analytics.setBreadcrumbs(this.state.breadcrumbs); + } + }, + + _onBreadcrumbsChanged: function(settingName, roomId, level, valueAtLevel, value) { + // Features are only possible at a single level, so we can get away with using valueAtLevel. + // The SettingsStore runs on the same tick as the update, so `value` will be wrong. + this.setState({breadcrumbs: valueAtLevel}); + + // For some reason the setState doesn't trigger a render of the component, so force one. + // Probably has to do with the change happening outside of a change detector cycle. + this.forceUpdate(); + }, + _onFocus: function(ev) { this.focusedElement = ev.target; }, @@ -220,7 +249,7 @@ const LeftPanel = React.createClass({ collapsed={this.props.collapsed} />); let breadcrumbs; - if (SettingsStore.isFeatureEnabled("feature_room_breadcrumbs")) { + if (this.state.breadcrumbs) { breadcrumbs = (); } @@ -234,14 +263,13 @@ const LeftPanel = React.createClass({
); - // }, }); diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index c6c1be67ec..4771c6f487 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -22,7 +22,6 @@ import PropTypes from 'prop-types'; import { DragDropContext } from 'react-beautiful-dnd'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; -import Notifier from '../../Notifier'; import PageTypes from '../../PageTypes'; import CallMediaHandler from '../../CallMediaHandler'; import sdk from '../../index'; @@ -121,6 +120,18 @@ const LoggedInView = React.createClass({ this._matrixClient.on("RoomState.events", this.onRoomStateEvents); }, + componentDidUpdate(prevProps) { + // attempt to guess when a banner was opened or closed + if ( + (prevProps.showCookieBar !== this.props.showCookieBar) || + (prevProps.hasNewVersion !== this.props.hasNewVersion) || + (prevProps.userHasGeneratedPassword !== this.props.userHasGeneratedPassword) || + (prevProps.showNotifierToolbar !== this.props.showNotifierToolbar) + ) { + this.props.resizeNotifier.notifyBannersChanged(); + } + }, + componentWillUnmount: function() { document.removeEventListener('keydown', this._onKeyDown); this._matrixClient.removeListener("accountData", this.onAccountData); @@ -173,6 +184,7 @@ const LoggedInView = React.createClass({ }, onResized: (size) => { window.localStorage.setItem("mx_lhs_size", '' + size); + this.props.resizeNotifier.notifyLeftHandleResized(); }, }; const resizer = new Resizer( @@ -448,6 +460,7 @@ const LoggedInView = React.createClass({ disabled={this.props.middleDisabled} collapsedRhs={this.props.collapsedRhs} ConferenceHandler={this.props.ConferenceHandler} + resizeNotifier={this.props.resizeNotifier} />; break; @@ -489,7 +502,6 @@ const LoggedInView = React.createClass({ }); let topBar; - const isGuest = this.props.matrixClient.isGuest(); if (this.state.syncErrorData && this.state.syncErrorData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { topBar = ; } else if (this.state.userHasGeneratedPassword) { topBar = ; - } else if ( - !isGuest && Notifier.supportsDesktopNotifications() && - !Notifier.isEnabled() && !Notifier.isToolbarHidden() - ) { + } else if (this.props.showNotifierToolbar) { topBar = ; } @@ -534,7 +543,7 @@ const LoggedInView = React.createClass({
diff --git a/src/components/structures/MainSplit.js b/src/components/structures/MainSplit.js index 0427130eea..64f841da97 100644 --- a/src/components/structures/MainSplit.js +++ b/src/components/structures/MainSplit.js @@ -27,6 +27,9 @@ export default class MainSplit extends React.Component { _onResized(size) { window.localStorage.setItem("mx_rhs_size", size); + if (this.props.resizeNotifier) { + this.props.resizeNotifier.notifyRightHandleResized(); + } } _createResizer() { diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 2622a6bf93..277985ba1d 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -29,6 +29,7 @@ import PlatformPeg from "../../PlatformPeg"; import SdkConfig from "../../SdkConfig"; import * as RoomListSorter from "../../RoomListSorter"; import dis from "../../dispatcher"; +import Notifier from '../../Notifier'; import Modal from "../../Modal"; import Tinter from "../../Tinter"; @@ -48,6 +49,7 @@ import { _t, getCurrentLanguage } from '../../languageHandler'; 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; @@ -194,6 +196,8 @@ export default React.createClass({ hideToSRUsers: false, syncError: null, // If the current syncing status is ERROR, the error object, otherwise null. + resizeNotifier: new ResizeNotifier(), + showNotifierToolbar: false, }; return s; }, @@ -245,17 +249,6 @@ export default React.createClass({ return this.state.defaultIsUrl || "https://vector.im"; }, - /** - * Whether to skip the server details phase of registration and start at the - * actual form. - * @return {boolean} - * If there was a configured default HS or default server name, skip the - * the server details. - */ - skipServerDetailsForRegistration() { - return !!this.state.defaultHsUrl; - }, - componentWillMount: function() { SdkConfig.put(this.props.config); @@ -316,6 +309,9 @@ export default React.createClass({ // N.B. we don't call the whole of setTheme() here as we may be // racing with the theme CSS download finishing from index.js Tinter.tint(); + + // For PersistentElement + this.state.resizeNotifier.on("middlePanelResized", this._dispatchTimelineResize); }, componentDidMount: function() { @@ -398,6 +394,7 @@ export default React.createClass({ dis.unregister(this.dispatcherRef); window.removeEventListener("focus", this.onFocus); window.removeEventListener('resize', this.handleResize); + this.state.resizeNotifier.removeListener("middlePanelResized", this._dispatchTimelineResize); }, componentWillUpdate: function(props, state) { @@ -556,19 +553,8 @@ export default React.createClass({ }, }); break; - case 'view_user': - // FIXME: ugly hack to expand the RightPanel and then re-dispatch. - if (this.state.collapsedRhs) { - setTimeout(()=>{ - dis.dispatch({ - action: 'show_right_panel', - }); - dis.dispatch({ - action: 'view_user', - member: payload.member, - }); - }, 0); - } + case 'view_user_info': + this._viewUser(payload.userId, payload.subAction); break; case 'view_room': // Takes either a room ID or room alias: if switching to a room the client is already @@ -588,8 +574,8 @@ export default React.createClass({ break; case 'view_user_settings': { const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog"); - Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {}, 'mx_SettingsDialog', - /*isPriority=*/false, /*isStatic=*/true); + Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {}, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); // View the welcome or home page if we need something to look at this._viewSomethingBehindModal(); @@ -638,8 +624,9 @@ export default React.createClass({ case 'view_invite': showRoomInviteDialog(payload.roomId); break; - case 'notifier_enabled': - this.forceUpdate(); + case 'notifier_enabled': { + this.setState({showNotifierToolbar: Notifier.shouldShowToolbar()}); + } break; case 'hide_left_panel': this.setState({ @@ -923,6 +910,22 @@ export default React.createClass({ this.notifyNewScreen('home'); }, + _viewUser: function(userId, subAction) { + // Wait for the first sync so that `getRoom` gives us a room object if it's + // in the sync response + const waitForSync = this.firstSyncPromise ? + this.firstSyncPromise.promise : Promise.resolve(); + waitForSync.then(() => { + if (subAction === 'chat') { + this._chatCreateOrReuse(userId); + return; + } + this.notifyNewScreen('user/' + userId); + this.setState({currentUserId: userId}); + this._setPage(PageTypes.UserView); + }); + }, + _setMxId: function(payload) { const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog'); const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, { @@ -1058,34 +1061,48 @@ export default React.createClass({ button: _t("Leave"), onFinished: (shouldLeave) => { if (shouldLeave) { - const d = MatrixClientPeg.get().leave(roomId); + const d = MatrixClientPeg.get().leaveRoomChain(roomId); // FIXME: controller shouldn't be loading a view :( const Loader = sdk.getComponent("elements.Spinner"); const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); - d.then(() => { + d.then((errors) => { modal.close(); + + for (const leftRoomId of Object.keys(errors)) { + const err = errors[leftRoomId]; + if (!err) continue; + + console.error("Failed to leave room " + leftRoomId + " " + err); + let title = _t("Failed to leave room"); + let message = _t("Server may be unavailable, overloaded, or you hit a bug."); + if (err.errcode === 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM') { + title = _t("Can't leave Server Notices room"); + message = _t( + "This room is used for important messages from the Homeserver, " + + "so you cannot leave it.", + ); + } else if (err && err.message) { + message = err.message; + } + Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, { + title: title, + description: message, + }); + return; + } + if (this.state.currentRoomId === roomId) { dis.dispatch({action: 'view_next_room'}); } }, (err) => { + // This should only happen if something went seriously wrong with leaving the chain. modal.close(); console.error("Failed to leave room " + roomId + " " + err); - let title = _t("Failed to leave room"); - let message = _t("Server may be unavailable, overloaded, or you hit a bug."); - if (err.errcode == 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM') { - title = _t("Can't leave Server Notices room"); - message = _t( - "This room is used for important messages from the Homeserver, " + - "so you cannot leave it.", - ); - } else if (err && err.message) { - message = err.message; - } Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, { - title: title, - description: message, + title: _t("Failed to leave room"), + description: _t("Unknown error"), }); }); } @@ -1173,7 +1190,7 @@ export default React.createClass({ * Called when a new logged in session has started */ _onLoggedIn: async function() { - this.setStateForNewView({view: VIEWS.LOGGED_IN}); + this.setStateForNewView({ view: VIEWS.LOGGED_IN }); if (this._is_registered) { this._is_registered = false; @@ -1306,7 +1323,10 @@ export default React.createClass({ self.firstSyncPromise.resolve(); dis.dispatch({action: 'focus_composer'}); - self.setState({ready: true}); + self.setState({ + ready: true, + showNotifierToolbar: Notifier.shouldShowToolbar(), + }); }); cli.on('Call.incoming', function(call) { // we dispatch this synchronously to make sure that the event @@ -1535,7 +1555,16 @@ export default React.createClass({ } else if (screen.indexOf('room/') == 0) { const segments = screen.substring(5).split('/'); const roomString = segments[0]; - const eventId = segments[1]; // undefined if no event id given + let eventId = segments.splice(1).join("/"); // empty string if no event id given + + // Previously we pulled the eventID from the segments in such a way + // where if there was no eventId then we'd get undefined. However, we + // now do a splice and join to handle v3 event IDs which results in + // an empty string. To maintain our potential contract with the rest + // of the app, we coerce the eventId to be undefined where applicable. + if (!eventId) eventId = undefined; + + // TODO: Handle encoded room/event IDs: https://github.com/vector-im/riot-web/issues/9149 // FIXME: sort_out caseConsistency const thirdPartyInvite = { @@ -1579,19 +1608,10 @@ export default React.createClass({ dis.dispatch(payload); } else if (screen.indexOf('user/') == 0) { const userId = screen.substring(5); - - // Wait for the first sync so that `getRoom` gives us a room object if it's - // in the sync response - const waitFor = this.firstSyncPromise ? - this.firstSyncPromise.promise : Promise.resolve(); - waitFor.then(() => { - if (params.action === 'chat') { - this._chatCreateOrReuse(userId); - return; - } - this.notifyNewScreen('user/' + userId); - this.setState({currentUserId: userId}); - this._setPage(PageTypes.UserView); + dis.dispatch({ + action: 'view_user_info', + userId: userId, + subAction: params.action, }); } else if (screen.indexOf('group/') == 0) { const groupId = screen.substring(6); @@ -1661,9 +1681,14 @@ export default React.createClass({ dis.dispatch({ action: 'show_right_panel' }); } + this.state.resizeNotifier.notifyWindowResized(); this._windowWidth = window.innerWidth; }, + _dispatchTimelineResize() { + dis.dispatch({ action: 'timeline_resize' }); + }, + onRoomCreated: function(roomId) { dis.dispatch({ action: "view_room", @@ -1720,7 +1745,7 @@ export default React.createClass({ hasCancelButton: false, }); - return; + return MatrixClientPeg.get(); } } return Lifecycle.setLoggedIn(credentials); @@ -1759,7 +1784,7 @@ export default React.createClass({ }, _setPageSubtitle: function(subtitle='') { - document.title = `Riot ${subtitle}`; + document.title = `${SdkConfig.get().brand || 'Riot'} ${subtitle}`; }, updateStatusIndicator: function(state, prevState) { @@ -1937,7 +1962,6 @@ export default React.createClass({ defaultServerDiscoveryError={this.state.defaultServerDiscoveryError} defaultHsUrl={this.getDefaultHsUrl()} defaultIsUrl={this.getDefaultIsUrl()} - skipServerDetails={this.skipServerDetailsForRegistration()} brand={this.props.config.brand} customHsUrl={this.getCurrentHsUrl()} customIsUrl={this.getCurrentIsUrl()} @@ -1980,7 +2004,6 @@ export default React.createClass({ fallbackHsUrl={this.getFallbackHsUrl()} defaultDeviceDisplayName={this.props.defaultDeviceDisplayName} onForgotPasswordClick={this.onForgotPasswordClick} - enableGuest={this.props.enableGuest} onServerConfigChange={this.onServerConfigChange} /> ); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index b1f88a6221..b57b659136 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -21,7 +21,6 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import shouldHideEvent from '../../shouldHideEvent'; import {wantsDateSeparator} from '../../DateUtils'; -import dis from "../../dispatcher"; import sdk from '../../index'; import MatrixClientPeg from '../../MatrixClientPeg'; @@ -628,16 +627,29 @@ module.exports = React.createClass({ _onHeightChanged: function() { const scrollPanel = this.refs.scrollPanel; if (scrollPanel) { - scrollPanel.forceUpdate(); + scrollPanel.checkScroll(); } }, - _onTypingVisible: function() { + _onTypingShown: function() { const scrollPanel = this.refs.scrollPanel; + // this will make the timeline grow, so checkScroll + scrollPanel.checkScroll(); if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { - // scroll down if at bottom + scrollPanel.preventShrinking(); + } + }, + + _onTypingHidden: function() { + const scrollPanel = this.refs.scrollPanel; + if (scrollPanel) { + // as hiding the typing notifications doesn't + // update the scrollPanel, we tell it to apply + // the shrinking prevention once the typing notifs are hidden + scrollPanel.updatePreventShrinking(); + // order is important here as checkScroll will scroll down to + // reveal added padding to balance the notifs disappearing. scrollPanel.checkScroll(); - scrollPanel.blockShrinking(); } }, @@ -653,22 +665,18 @@ module.exports = React.createClass({ // update the min-height, so once the last // person stops typing, no jumping occurs if (isAtBottom && isTypingVisible) { - scrollPanel.blockShrinking(); + scrollPanel.preventShrinking(); } } }, - clearTimelineHeight: function() { + onTimelineReset: function() { const scrollPanel = this.refs.scrollPanel; if (scrollPanel) { - scrollPanel.clearBlockShrinking(); + scrollPanel.clearPreventShrinking(); } }, - onResize: function() { - dis.dispatch({ action: 'timeline_resize' }, true); - }, - render: function() { const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile"); @@ -693,7 +701,12 @@ module.exports = React.createClass({ let whoIsTyping; if (this.props.room) { - whoIsTyping = (); + whoIsTyping = ( + ); } return ( @@ -703,7 +716,8 @@ module.exports = React.createClass({ onFillRequest={this.props.onFillRequest} onUnfillRequest={this.props.onUnfillRequest} style={style} - stickyBottom={this.props.stickyBottom}> + stickyBottom={this.props.stickyBottom} + resizeNotifier={this.props.resizeNotifier}> { topSpinner } { this._getEventTiles() } { whoIsTyping } diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 5c745b04cc..a1e0af3606 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -50,6 +50,7 @@ export default class RightPanel extends React.Component { FilePanel: 'FilePanel', NotificationPanel: 'NotificationPanel', RoomMemberInfo: 'RoomMemberInfo', + Room3pidMemberInfo: 'Room3pidMemberInfo', GroupMemberInfo: 'GroupMemberInfo', }); @@ -155,6 +156,7 @@ export default class RightPanel extends React.Component { groupRoomId: payload.groupRoomId, groupId: payload.groupId, member: payload.member, + event: payload.event, }); } } @@ -162,6 +164,7 @@ export default class RightPanel extends React.Component { render() { const MemberList = sdk.getComponent('rooms.MemberList'); const MemberInfo = sdk.getComponent('rooms.MemberInfo'); + const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo'); const NotificationPanel = sdk.getComponent('structures.NotificationPanel'); const FilePanel = sdk.getComponent('structures.FilePanel'); @@ -180,6 +183,8 @@ export default class RightPanel extends React.Component { panel = ; } else if (this.state.phase === RightPanel.Phase.RoomMemberInfo) { panel = ; + } else if (this.state.phase === RightPanel.Phase.Room3pidMemberInfo) { + panel = ; } else if (this.state.phase === RightPanel.Phase.GroupMemberInfo) { panel = ; } else if (this.state.phase === RightPanel.Phase.FilePanel) { - panel = ; + panel = ; } const classes = classNames("mx_RightPanel", "mx_fadable", { diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index e13eab8eb3..5342276e63 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -26,14 +26,17 @@ const dis = require('../../dispatcher'); import { linkifyAndSanitizeHtml } from '../../HtmlUtils'; import Promise from 'bluebird'; - import { _t } from '../../languageHandler'; - -import {instanceForInstanceId, protocolNameForInstanceId} from '../../utils/DirectoryUtils'; +import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; +import Analytics from '../../Analytics'; const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 160; +function track(action) { + Analytics.trackEvent('RoomDirectory', action); +} + module.exports = React.createClass({ displayName: 'RoomDirectory', @@ -53,6 +56,7 @@ module.exports = React.createClass({ publicRooms: [], loading: true, protocolsLoading: true, + error: null, instanceId: null, includeAll: false, roomServer: null, @@ -95,10 +99,12 @@ module.exports = React.createClass({ // thing you see when loading the client! return; } - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to get protocol list from homeserver', '', ErrorDialog, { - title: _t('Failed to get protocol list from homeserver'), - description: _t('The homeserver may be too old to support third party networks'), + track('Failed to get protocol list from homeserver'); + this.setState({ + error: _t( + 'Riot failed to get the protocol list from the homeserver. ' + + 'The homeserver may be too old to support third party networks.', + ), }); }); @@ -187,12 +193,14 @@ module.exports = React.createClass({ return; } - this.setState({ loading: false }); console.error("Failed to get publicRooms: %s", JSON.stringify(err)); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to get public room list', '', ErrorDialog, { - title: _t('Failed to get public room list'), - description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded')), + track('Failed to get public room list'); + this.setState({ + loading: false, + error: + `${_t('Riot failed to get the public room list.')} ` + + `${(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')}` + , }); }); }, @@ -511,25 +519,15 @@ module.exports = React.createClass({ }, render: function() { - const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader'); const Loader = sdk.getComponent("elements.Spinner"); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - // TODO: clean this up - if (this.state.protocolsLoading) { - return ( -
- -
- ); - } - let content; - if (this.state.loading) { - content =
- -
; + if (this.state.error) { + content = this.state.error; + } else if (this.state.protocolsLoading || this.state.loading) { + content = ; } else { const rows = this.getRows(); // we still show the scrollpanel, at least for now, because @@ -551,39 +549,53 @@ module.exports = React.createClass({ onFillRequest={ this.onFillRequest } stickyBottom={false} startAtBottom={false} - onResize={function() {}} > { scrollpanel_content } ; } - const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); - let instance_expected_field_type; - if ( - protocolName && - this.protocols && - this.protocols[protocolName] && - this.protocols[protocolName].location_fields.length > 0 && - this.protocols[protocolName].field_types - ) { - const last_field = this.protocols[protocolName].location_fields.slice(-1)[0]; - instance_expected_field_type = this.protocols[protocolName].field_types[last_field]; - } + let listHeader; + if (!this.state.protocolsLoading) { + const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown'); + const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox'); - - let placeholder = _t('Search for a room'); - if (!this.state.instanceId) { - placeholder = _t('Search for a room like #example') + ':' + this.state.roomServer; - } else if (instance_expected_field_type) { - placeholder = instance_expected_field_type.placeholder; - } - - let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type); - if (protocolName) { - const instance = instanceForInstanceId(this.protocols, this.state.instanceId); - if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) { - showJoinButton = false; + const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); + let instance_expected_field_type; + if ( + protocolName && + this.protocols && + this.protocols[protocolName] && + this.protocols[protocolName].location_fields.length > 0 && + this.protocols[protocolName].field_types + ) { + const last_field = this.protocols[protocolName].location_fields.slice(-1)[0]; + instance_expected_field_type = this.protocols[protocolName].field_types[last_field]; } + + + let placeholder = _t('Search for a room'); + if (!this.state.instanceId) { + placeholder = _t('Search for a room like #example') + ':' + this.state.roomServer; + } else if (instance_expected_field_type) { + placeholder = instance_expected_field_type.placeholder; + } + + let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type); + if (protocolName) { + const instance = instanceForInstanceId(this.protocols, this.state.instanceId); + if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) { + showJoinButton = false; + } + } + + listHeader =
+ + +
; } const createRoomButton = ({_t("Create new room")}); - const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown'); - const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox'); return (
-
- - -
+ {listHeader} {content}
diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index 78cc5bd58f..d094d88c24 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -1,7 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd +Copyright 2018, 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. @@ -146,8 +146,8 @@ const RoomSubList = React.createClass({ key={room.roomId} collapsed={this.props.collapsed || false} unread={Unread.doesRoomHaveUnreadMessages(room)} - highlight={room.getUnreadNotificationCount('highlight') > 0 || this.props.isInvite} - notificationCount={room.getUnreadNotificationCount()} + highlight={this.props.isInvite || RoomNotifs.getUnreadNotificationCount(room, 'highlight') > 0} + notificationCount={RoomNotifs.getUnreadNotificationCount(room)} isInvite={this.props.isInvite} refreshSubList={this._updateSubListCount} incomingCall={null} diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 34c711ee6f..99f13dcd35 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -19,29 +19,28 @@ limitations under the License. // TODO: This component is enormous! There's several things which could stand-alone: // - Search results component // - Drag and drop -// - File uploading - uploadFile() -import shouldHideEvent from "../../shouldHideEvent"; +import shouldHideEvent from '../../shouldHideEvent'; -const React = require("react"); -const ReactDOM = require("react-dom"); +import React from 'react'; +import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import Promise from 'bluebird'; import filesize from 'filesize'; -const classNames = require("classnames"); +import classNames from 'classnames'; import { _t } from '../../languageHandler'; -import {RoomPermalinkCreator} from "../../matrix-to"; +import {RoomPermalinkCreator} from '../../matrix-to'; -const MatrixClientPeg = require("../../MatrixClientPeg"); -const ContentMessages = require("../../ContentMessages"); -const Modal = require("../../Modal"); -const sdk = require('../../index'); -const CallHandler = require('../../CallHandler'); -const dis = require("../../dispatcher"); -const Tinter = require("../../Tinter"); -const rate_limited_func = require('../../ratelimitedfunc'); -const ObjectUtils = require('../../ObjectUtils'); -const Rooms = require('../../Rooms'); +import MatrixClientPeg from '../../MatrixClientPeg'; +import ContentMessages from '../../ContentMessages'; +import Modal from '../../Modal'; +import sdk from '../../index'; +import CallHandler from '../../CallHandler'; +import dis from '../../dispatcher'; +import Tinter from '../../Tinter'; +import rate_limited_func from '../../ratelimitedfunc'; +import ObjectUtils from '../../ObjectUtils'; +import Rooms from '../../Rooms'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; @@ -52,6 +51,7 @@ import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; import WidgetEchoStore from '../../stores/WidgetEchoStore'; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import WidgetUtils from '../../utils/WidgetUtils'; +import AccessibleButton from "../views/elements/AccessibleButton"; const DEBUG = false; let debuglog = function() {}; @@ -144,6 +144,7 @@ module.exports = React.createClass({ // the end of the live timeline. It has the effect of hiding the // 'scroll to bottom' knob, among a couple of other things. atEndOfLiveTimeline: true, + atEndOfLiveTimelineInit: false, // used by componentDidUpdate to avoid unnecessary checks showTopUnreadMessagesBar: false, @@ -168,7 +169,6 @@ module.exports = React.createClass({ MatrixClientPeg.get().on("accountData", this.onAccountData); MatrixClientPeg.get().on("crypto.keyBackupStatus", this.onKeyBackupStatus); MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged); - this._fetchMediaConfig(); // Start listening for RoomViewStore updates this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); this._onRoomViewStoreUpdate(true); @@ -176,27 +176,6 @@ module.exports = React.createClass({ WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate); }, - _fetchMediaConfig: function(invalidateCache: boolean = false) { - /// NOTE: Using global here so we don't make repeated requests for the - /// config every time we swap room. - if(global.mediaConfig !== undefined && !invalidateCache) { - this.setState({mediaConfig: global.mediaConfig}); - return; - } - console.log("[Media Config] Fetching"); - MatrixClientPeg.get().getMediaConfig().then((config) => { - console.log("[Media Config] Fetched config:", config); - return config; - }).catch(() => { - // Media repo can't or won't report limits, so provide an empty object (no limits). - console.log("[Media Config] Could not fetch config, so not limiting uploads."); - return {}; - }).then((config) => { - global.mediaConfig = config; - this.setState({mediaConfig: config}); - }); - }, - _onRoomViewStoreUpdate: function(initial) { if (this.unmounted) { return; @@ -293,6 +272,28 @@ module.exports = React.createClass({ return this.state.room ? this.state.room.roomId : this.state.roomId; }, + _getPermalinkCreatorForRoom: function(room) { + if (!this._permalinkCreators) this._permalinkCreators = {}; + if (this._permalinkCreators[room.roomId]) return this._permalinkCreators[room.roomId]; + + this._permalinkCreators[room.roomId] = new RoomPermalinkCreator(room); + if (this.state.room && room.roomId === this.state.room.roomId) { + // We want to watch for changes in the creator for the primary room in the view, but + // don't need to do so for search results. + this._permalinkCreators[room.roomId].start(); + } else { + this._permalinkCreators[room.roomId].load(); + } + return this._permalinkCreators[room.roomId]; + }, + + _stopAllPermalinkCreators: function() { + if (!this._permalinkCreators) return; + for (const roomId of Object.keys(this._permalinkCreators)) { + this._permalinkCreators[roomId].stop(); + } + }, + _onWidgetEchoStoreUpdate: function() { this.setState({ showApps: this._shouldShowApps(this.state.room), @@ -392,7 +393,9 @@ module.exports = React.createClass({ this._updateConfCallNotification(); window.addEventListener('beforeunload', this.onPageUnload); - window.addEventListener('resize', this.onResize); + if (this.props.resizeNotifier) { + this.props.resizeNotifier.on("middlePanelResized", this.onResize); + } this.onResize(); document.addEventListener("keydown", this.onKeyDown); @@ -428,6 +431,18 @@ module.exports = React.createClass({ roomView.addEventListener('dragend', this.onDragLeaveOrEnd); } } + + // Note: We check the ref here with a flag because componentDidMount, despite + // documentation, does not define our messagePanel ref. It looks like our spinner + // in render() prevents the ref from being set on first mount, so we try and + // catch the messagePanel when it does mount. Because we only want the ref once, + // we use a boolean flag to avoid duplicate work. + if (this.refs.messagePanel && !this.state.atEndOfLiveTimelineInit) { + this.setState({ + atEndOfLiveTimelineInit: true, + atEndOfLiveTimeline: this.refs.messagePanel.isAtEndOfLiveTimeline(), + }); + } }, componentWillUnmount: function() { @@ -443,9 +458,7 @@ module.exports = React.createClass({ } // stop tracking room changes to format permalinks - if (this.state.permalinkCreator) { - this.state.permalinkCreator.stop(); - } + this._stopAllPermalinkCreators(); if (this.refs.roomView) { // disconnect the D&D event listeners from the room view. This @@ -472,7 +485,9 @@ module.exports = React.createClass({ } window.removeEventListener('beforeunload', this.onPageUnload); - window.removeEventListener('resize', this.onResize); + if (this.props.resizeNotifier) { + this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); + } document.removeEventListener("keydown", this.onKeyDown); @@ -492,7 +507,7 @@ module.exports = React.createClass({ }, onPageUnload(event) { - if (ContentMessages.getCurrentUploads().length > 0) { + if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { return event.returnValue = _t("You seem to be uploading files, are you sure you want to quit?"); } else if (this._getCallForRoom() && this.state.callState !== 'ended') { @@ -541,16 +556,14 @@ module.exports = React.createClass({ payload.data.description || payload.data.name); break; case 'picture_snapshot': - this.uploadFile(payload.file); + return ContentMessages.sharedInstance().sendContentListToRoom( + [payload.file], this.state.room.roomId, MatrixClientPeg.get(), + ); break; - case 'upload_failed': - // 413: File was too big or upset the server in some way. - if (payload.error && payload.error.http_status === 413) { - this._fetchMediaConfig(true); - } case 'notifier_enabled': case 'upload_started': case 'upload_finished': + case 'upload_canceled': this.forceUpdate(); break; case 'call_state': @@ -658,11 +671,6 @@ module.exports = React.createClass({ this._loadMembersIfJoined(room); this._calculateRecommendedVersion(room); this._updateE2EStatus(room); - if (!this.state.permalinkCreator) { - const permalinkCreator = new RoomPermalinkCreator(room); - permalinkCreator.start(); - this.setState({permalinkCreator}); - } }, _calculateRecommendedVersion: async function(room) { @@ -738,8 +746,19 @@ module.exports = React.createClass({ if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) { return; } + if (!MatrixClientPeg.get().isCryptoEnabled()) { + // If crypto is not currently enabled, we aren't tracking devices at all, + // so we don't know what the answer is. Let's error on the safe side and show + // a warning for this case. + this.setState({ + e2eStatus: "warning", + }); + return; + } room.hasUnverifiedDevices().then((hasUnverifiedDevices) => { - this.setState({e2eStatus: hasUnverifiedDevices ? "warning" : "verified"}); + this.setState({ + e2eStatus: hasUnverifiedDevices ? "warning" : "verified", + }); }); }, @@ -865,10 +884,6 @@ module.exports = React.createClass({ } }, - onSearchResultsResize: function() { - dis.dispatch({ action: 'timeline_resize' }, true); - }, - onSearchResultsFillRequest: function(backwards) { if (!backwards) { return Promise.resolve(false); @@ -1001,9 +1016,11 @@ module.exports = React.createClass({ onDrop: function(ev) { ev.stopPropagation(); ev.preventDefault(); + ContentMessages.sharedInstance().sendContentListToRoom( + ev.dataTransfer.files, this.state.room.roomId, MatrixClientPeg.get(), + ); this.setState({ draggingFile: false }); - const files = [...ev.dataTransfer.files]; - files.forEach(this.uploadFile); + dis.dispatch({action: 'focus_composer'}); }, onDragLeaveOrEnd: function(ev) { @@ -1012,55 +1029,13 @@ module.exports = React.createClass({ this.setState({ draggingFile: false }); }, - isFileUploadAllowed(file) { - if (this.state.mediaConfig !== undefined && - this.state.mediaConfig["m.upload.size"] !== undefined && - file.size > this.state.mediaConfig["m.upload.size"]) { - return _t("File is too big. Maximum file size is %(fileSize)s", {fileSize: filesize(this.state.mediaConfig["m.upload.size"])}); - } - return true; - }, - - uploadFile: async function(file) { - dis.dispatch({action: 'focus_composer'}); - - if (MatrixClientPeg.get().isGuest()) { - dis.dispatch({action: 'require_registration'}); - return; - } - - try { - await ContentMessages.sendContentToRoom(file, this.state.room.roomId, MatrixClientPeg.get()); - } catch (error) { - if (error.name === "UnknownDeviceError") { - // Let the status bar handle this - return; - } - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - console.error("Failed to upload file " + file + " " + error); - Modal.createTrackedDialog('Failed to upload file', '', ErrorDialog, { - title: _t('Failed to upload file'), - description: ((error && error.message) - ? error.message : _t("Server may be unavailable, overloaded, or the file too big")), - }); - - // bail early to avoid calling the dispatch below - return; - } - - // Send message_sent callback, for things like _checkIfAlone because after all a file is still a message. - dis.dispatch({ - action: 'message_sent', - }); - }, - injectSticker: function(url, info, text) { if (MatrixClientPeg.get().isGuest()) { dis.dispatch({action: 'require_registration'}); return; } - ContentMessages.sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get()) + ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get()) .done(undefined, (error) => { if (error.name === "UnknownDeviceError") { // Let the staus bar handle this @@ -1209,6 +1184,7 @@ module.exports = React.createClass({ const mxEv = result.context.getEvent(); const roomId = mxEv.getRoomId(); + const room = cli.getRoom(roomId); if (!EventTile.haveTileForEvent(mxEv)) { // XXX: can this ever happen? It will make the result count @@ -1218,7 +1194,6 @@ module.exports = React.createClass({ if (this.state.searchScope === 'All') { if (roomId != lastRoomId) { - const room = cli.getRoom(roomId); // XXX: if we've left the room, we might not know about // it. We should tell the js sdk to go and find out about @@ -1239,7 +1214,7 @@ module.exports = React.createClass({ searchResult={result} searchHighlights={this.state.searchHighlights} resultLink={resultLink} - permalinkCreator={this.state.permalinkCreator} + permalinkCreator={this._getPermalinkCreatorForRoom(room)} onHeightChanged={onHeightChanged} />); } return ret; @@ -1364,8 +1339,7 @@ module.exports = React.createClass({ const showBar = this.refs.messagePanel.canJumpToReadMarker(); if (this.state.showTopUnreadMessagesBar != showBar) { - this.setState({showTopUnreadMessagesBar: showBar}, - this.onChildResize); + this.setState({showTopUnreadMessagesBar: showBar}); } }, @@ -1408,7 +1382,7 @@ module.exports = React.createClass({ }; }, - onResize: function(e) { + onResize: function() { // It seems flexbox doesn't give us a way to constrain the auxPanel height to have // a minimum of the height of the video element, whilst also capping it from pushing out the page // so we have to do it via JS instead. In this implementation we cap the height by putting @@ -1426,9 +1400,6 @@ module.exports = React.createClass({ if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; this.setState({auxPanelMaxHeight: auxPanelMaxHeight}); - - // changing the maxHeight on the auxpanel will trigger a callback go - // onChildResize, so no need to worry about that here. }, onFullscreenClick: function() { @@ -1458,10 +1429,6 @@ module.exports = React.createClass({ this.forceUpdate(); // TODO: just update the voip buttons }, - onChildResize: function() { - // no longer anything to do here - }, - onStatusBarVisible: function() { if (this.unmounted) return; this.setState({ @@ -1515,6 +1482,25 @@ module.exports = React.createClass({ } }, + _getOldRoom: function() { + const createEvent = this.state.room.currentState.getStateEvents("m.room.create", ""); + if (!createEvent || !createEvent.getContent()['predecessor']) return null; + + return MatrixClientPeg.get().getRoom(createEvent.getContent()['predecessor']['room_id']); + }, + + _getHiddenHighlightCount: function() { + const oldRoom = this._getOldRoom(); + if (!oldRoom) return 0; + return oldRoom.getUnreadNotificationCount('highlight'); + }, + + _onHiddenHighlightsClick: function() { + const oldRoom = this._getOldRoom(); + if (!oldRoom) return; + dis.dispatch({action: "view_room", room_id: oldRoom.roomId}); + }, + render: function() { const RoomHeader = sdk.getComponent('rooms.RoomHeader'); const MessageComposer = sdk.getComponent('rooms.MessageComposer'); @@ -1525,16 +1511,21 @@ module.exports = React.createClass({ const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); const TintableSvg = sdk.getComponent("elements.TintableSvg"); const RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar"); - const Loader = sdk.getComponent("elements.Spinner"); const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); const RoomUpgradeWarningBar = sdk.getComponent("rooms.RoomUpgradeWarningBar"); const RoomRecoveryReminder = sdk.getComponent("rooms.RoomRecoveryReminder"); if (!this.state.room) { - if (this.state.roomLoading || this.state.peekLoading) { + const loading = this.state.roomLoading || this.state.peekLoading; + if (loading) { return (
- +
); } else { @@ -1552,28 +1543,16 @@ module.exports = React.createClass({ const roomAlias = this.state.roomAlias; return (
- -
-
- -
-
-
); } @@ -1583,9 +1562,12 @@ module.exports = React.createClass({ if (myMembership == 'invite') { if (this.state.joining || this.state.rejecting) { return ( -
- -
+ ); } else { const myUserId = MatrixClientPeg.get().credentials.userId; @@ -1600,26 +1582,14 @@ module.exports = React.createClass({ // We have a regular invite for this room. return (
- -
-
- -
-
-
); } @@ -1641,7 +1611,7 @@ module.exports = React.createClass({ let statusBar; let isStatusAreaExpanded = true; - if (ContentMessages.getCurrentUploads().length > 0) { + if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { const UploadBar = sdk.getComponent('structures.UploadBar'); statusBar = ; } else if (!this.state.searchResults) { @@ -1654,7 +1624,6 @@ module.exports = React.createClass({ isPeeking={myMembership !== "join"} onInviteClick={this.onInviteButtonClick} onStopWarningClick={this.onStopAloneWarningClick} - onResize={this.onChildResize} onVisible={this.onStatusBarVisible} onHidden={this.onStatusBarHidden} />; @@ -1673,8 +1642,12 @@ module.exports = React.createClass({ !MatrixClientPeg.get().getKeyBackupEnabled() ); + const hiddenHighlightCount = this._getHiddenHighlightCount(); + let aux = null; + let previewBar; let hideCancel = false; + let hideRightPanel = false; if (this.state.forwardingEvent !== null) { aux = ; } else if (this.state.searching) { @@ -1701,18 +1674,36 @@ module.exports = React.createClass({ invitedEmail = this.props.thirdPartyInvite.invitedEmail; } hideCancel = true; - aux = ( + previewBar = ( ); + if (!this.state.canPeek) { + return ( +
+ { previewBar } +
+ ); + } else { + hideRightPanel = true; + } + } else if (hiddenHighlightCount > 0) { + aux = ( + + {_t( + "You have %(count)s unread notifications in a prior version of this room.", + {count: hiddenHighlightCount}, + )} + + ); } const auxPanel = ( @@ -1723,7 +1714,6 @@ module.exports = React.createClass({ draggingFile={this.state.draggingFile} displayConfCallNotification={this.state.displayConfCallNotification} maxHeight={this.state.auxPanelMaxHeight} - onResize={this.onChildResize} showApps={this.state.showApps} hideAppsDrawer={false} > { aux } @@ -1739,22 +1729,14 @@ module.exports = React.createClass({ messageComposer = ; } - if (MatrixClientPeg.get().isGuest()) { - const AuthButtons = sdk.getComponent('views.auth.AuthButtons'); - messageComposer = ; - } - // TODO: Why aren't we storing the term/scope/count in this format // in this.state if this is what RoomHeader desires? if (this.state.searchResults) { @@ -1814,7 +1796,7 @@ module.exports = React.createClass({
  • { this.getSearchResultTiles() } @@ -1848,7 +1830,8 @@ module.exports = React.createClass({ showUrlPreview = {this.state.showUrlPreview} className="mx_RoomView_messagePanel" membersLoaded={this.state.membersLoaded} - permalinkCreator={this.state.permalinkCreator} + permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)} + resizeNotifier={this.props.resizeNotifier} />); let topUnreadMessagesBar = null; @@ -1881,14 +1864,16 @@ module.exports = React.createClass({ }, ); - const rightPanel = this.state.room ? : undefined; + const rightPanel = !hideRightPanel && this.state.room && + ; + const collapsedRhs = hideRightPanel || this.props.collapsedRhs; return (
    - +
    { auxPanel }
    @@ -1912,6 +1901,7 @@ module.exports = React.createClass({ { statusBar }
    + { previewBar } { messageComposer }
    diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index ee4045c91e..7e1f0ff469 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -15,14 +15,13 @@ limitations under the License. */ const React = require("react"); -const ReactDOM = require("react-dom"); import PropTypes from 'prop-types'; import Promise from 'bluebird'; import { KeyCode } from '../../Keyboard'; -import sdk from '../../index.js'; +import Timer from '../../utils/Timer'; +import AutoHideScrollbar from "./AutoHideScrollbar"; const DEBUG_SCROLL = false; -// var DEBUG_SCROLL = true; // The amount of extra scroll distance to allow prior to unfilling. // See _getExcessHeight. @@ -30,12 +29,18 @@ const UNPAGINATION_PADDING = 6000; // The number of milliseconds to debounce calls to onUnfillRequest, to prevent // many scroll events causing many unfilling requests. const UNFILL_REQUEST_DEBOUNCE_MS = 200; +// _updateHeight makes the height a ceiled multiple of this so we +// don't have to update the height too often. It also allows the user +// to scroll past the pagination spinner a bit so they don't feel blocked so +// much while the content loads. +const PAGE_SIZE = 400; +let debuglog; if (DEBUG_SCROLL) { // using bind means that we get to keep useful line numbers in the console - var debuglog = console.log.bind(console); + debuglog = console.log.bind(console, "ScrollPanel debuglog:"); } else { - var debuglog = function() {}; + debuglog = function() {}; } /* This component implements an intelligent scrolling list. @@ -129,11 +134,6 @@ module.exports = React.createClass({ */ onScroll: PropTypes.func, - /* onResize: a callback which is called whenever the Gemini scroll - * panel is resized - */ - onResize: PropTypes.func, - /* className: classnames to add to the top-level div */ className: PropTypes.string, @@ -141,6 +141,9 @@ module.exports = React.createClass({ /* style: styles to add to the top-level div */ style: PropTypes.object, + /* resizeNotifier: ResizeNotifier to know when middle column has changed size + */ + resizeNotifier: PropTypes.object, }, getDefaultProps: function() { @@ -150,12 +153,18 @@ module.exports = React.createClass({ onFillRequest: function(backwards) { return Promise.resolve(false); }, onUnfillRequest: function(backwards, scrollToken) {}, onScroll: function() {}, - onResize: function() {}, }; }, componentWillMount: function() { + this._fillRequestWhileRunning = false; + this._isFilling = false; this._pendingFillRequests = {b: null, f: null}; + + if (this.props.resizeNotifier) { + this.props.resizeNotifier.on("middlePanelResized", this.onResize); + } + this.resetScrollState(); }, @@ -170,6 +179,7 @@ module.exports = React.createClass({ // // This will also re-check the fill state, in case the paginate was inadequate this.checkScroll(); + this.updatePreventShrinking(); }, componentWillUnmount: function() { @@ -178,81 +188,49 @@ module.exports = React.createClass({ // // (We could use isMounted(), but facebook have deprecated that.) this.unmounted = true; + + if (this.props.resizeNotifier) { + this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); + } }, onScroll: function(ev) { - const sn = this._getScrollNode(); - debuglog("Scroll event: offset now:", sn.scrollTop, - "_lastSetScroll:", this._lastSetScroll); - - // Sometimes we see attempts to write to scrollTop essentially being - // ignored. (Or rather, it is successfully written, but on the next - // scroll event, it's been reset again). - // - // This was observed on Chrome 47, when scrolling using the trackpad in OS - // X Yosemite. Can't reproduce on El Capitan. Our theory is that this is - // due to Chrome not being able to cope with the scroll offset being reset - // while a two-finger drag is in progress. - // - // By way of a workaround, we detect this situation and just keep - // resetting scrollTop until we see the scroll node have the right - // value. - if (this._lastSetScroll !== undefined && sn.scrollTop < this._lastSetScroll-200) { - console.log("Working around vector-im/vector-web#528"); - this._restoreSavedScrollState(); - return; - } - - // If there weren't enough children to fill the viewport, the scroll we - // got might be different to the scroll we wanted; we don't want to - // forget what we wanted, so don't overwrite the saved state unless - // this appears to be a user-initiated scroll. - if (sn.scrollTop != this._lastSetScroll) { - this._saveScrollState(); - } else { - debuglog("Ignoring scroll echo"); - // only ignore the echo once, otherwise we'll get confused when the - // user scrolls away from, and back to, the autoscroll point. - this._lastSetScroll = undefined; - } - - this._checkBlockShrinking(); - + debuglog("onScroll", this._getScrollNode().scrollTop); + this._scrollTimeout.restart(); + this._saveScrollState(); + this.updatePreventShrinking(); this.props.onScroll(ev); - this.checkFillState(); }, onResize: function() { - this.clearBlockShrinking(); - this.props.onResize(); this.checkScroll(); - if (this._gemScroll) this._gemScroll.forceUpdate(); + // update preventShrinkingState if present + if (this.preventShrinkingState) { + this.preventShrinking(); + } }, // 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() { this._restoreSavedScrollState(); - this._checkBlockShrinking(); this.checkFillState(); }, // return true if the content is fully scrolled down right now; else false. // // note that this is independent of the 'stuckAtBottom' state - it is simply - // about whether the the content is scrolled down right now, irrespective of + // about whether the content is scrolled down right now, irrespective of // whether it will stay that way when the children update. isAtBottom: function() { const sn = this._getScrollNode(); + // fractional values (both too big and too small) + // for scrollTop happen on certain browsers/platforms + // when scrolled all the way down. E.g. Chrome 72 on debian. + // so check difference <= 1; + return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1; - // there seems to be some bug with flexbox/gemini/chrome/richvdh's - // understanding of the box model, wherein the scrollNode ends up 2 - // pixels higher than the available space, even when there are less - // than a screenful of messages. + 3 is a fudge factor to pretend - // that we're at the bottom when we're still a few pixels off. - - return sn.scrollHeight - Math.ceil(sn.scrollTop) <= sn.clientHeight + 3; }, // returns the vertical height in the given direction that can be removed from @@ -288,19 +266,25 @@ module.exports = React.createClass({ // `---------' - _getExcessHeight: function(backwards) { const sn = this._getScrollNode(); + const contentHeight = this._getMessagesHeight(); + const listHeight = this._getListHeight(); + const clippedHeight = contentHeight - listHeight; + const unclippedScrollTop = sn.scrollTop + clippedHeight; + if (backwards) { - return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING; + return unclippedScrollTop - sn.clientHeight - UNPAGINATION_PADDING; } else { - return sn.scrollHeight - (sn.scrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING; + return contentHeight - (unclippedScrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING; } }, // check the scroll state and send out backfill requests if necessary. - checkFillState: function() { + checkFillState: async function(depth=0) { if (this.unmounted) { return; } + const isFirstCall = depth === 0; const sn = this._getScrollNode(); // if there is less than a screenful of messages above or below the @@ -327,13 +311,53 @@ module.exports = React.createClass({ // `---------' - // - if (sn.scrollTop < sn.clientHeight) { - // need to back-fill - this._maybeFill(true); + // as filling is async and recursive, + // don't allow more than 1 chain of calls concurrently + // do make a note when a new request comes in while already running one, + // so we can trigger a new chain of calls once done. + if (isFirstCall) { + if (this._isFilling) { + debuglog("_isFilling: not entering while request is ongoing, marking for a subsequent request"); + this._fillRequestWhileRunning = true; + return; + } + debuglog("_isFilling: setting"); + this._isFilling = true; } - if (sn.scrollTop > sn.scrollHeight - sn.clientHeight * 2) { + + const itemlist = this.refs.itemlist; + const firstTile = itemlist && itemlist.firstElementChild; + const contentTop = firstTile && firstTile.offsetTop; + const fillPromises = []; + + // if scrollTop gets to 1 screen from the top of the first tile, + // try backward filling + if (!firstTile || (sn.scrollTop - contentTop) < sn.clientHeight) { + // need to back-fill + fillPromises.push(this._maybeFill(depth, true)); + } + // if scrollTop gets to 2 screens from the end (so 1 screen below viewport), + // try forward filling + if ((sn.scrollHeight - sn.scrollTop) < sn.clientHeight * 2) { // need to forward-fill - this._maybeFill(false); + fillPromises.push(this._maybeFill(depth, false)); + } + + if (fillPromises.length) { + try { + await Promise.all(fillPromises); + } catch (err) { + console.error(err); + } + } + if (isFirstCall) { + debuglog("_isFilling: clearing"); + this._isFilling = false; + } + + if (this._fillRequestWhileRunning) { + this._fillRequestWhileRunning = false; + this.checkFillState(); } }, @@ -343,6 +367,9 @@ module.exports = React.createClass({ if (excessHeight <= 0) { return; } + + const origExcessHeight = excessHeight; + const tiles = this.refs.itemlist.children; // The scroll token of the first/last tile to be unpaginated @@ -354,8 +381,9 @@ module.exports = React.createClass({ // pagination. // // If backwards is true, we unpaginate (remove) tiles from the back (top). + let tile; for (let i = 0; i < tiles.length; i++) { - const tile = tiles[backwards ? i : tiles.length - 1 - i]; + tile = tiles[backwards ? i : tiles.length - 1 - i]; // Subtract height of tile as if it were unpaginated excessHeight -= tile.clientHeight; //If removing the tile would lead to future pagination, break before setting scroll token @@ -376,26 +404,31 @@ module.exports = React.createClass({ } this._unfillDebouncer = setTimeout(() => { this._unfillDebouncer = null; + debuglog("unfilling now", backwards, origExcessHeight); this.props.onUnfillRequest(backwards, markerScrollToken); }, UNFILL_REQUEST_DEBOUNCE_MS); } }, // check if there is already a pending fill request. If not, set one off. - _maybeFill: function(backwards) { + _maybeFill: function(depth, backwards) { const dir = backwards ? 'b' : 'f'; if (this._pendingFillRequests[dir]) { - debuglog("ScrollPanel: Already a "+dir+" fill in progress - not starting another"); + debuglog("Already a "+dir+" fill in progress - not starting another"); return; } - debuglog("ScrollPanel: starting "+dir+" fill"); + debuglog("starting "+dir+" fill"); // onFillRequest can end up calling us recursively (via onScroll // events) so make sure we set this before firing off the call. this._pendingFillRequests[dir] = true; - Promise.try(() => { + // wait 1ms before paginating, because otherwise + // this will block the scroll event handler for +700ms + // if messages are already cached in memory, + // This would cause jumping to happen on Chrome/macOS. + return new Promise(resolve => setTimeout(resolve, 1)).then(() => { return this.props.onFillRequest(backwards); }).finally(() => { this._pendingFillRequests[dir] = false; @@ -406,14 +439,14 @@ module.exports = React.createClass({ // Unpaginate once filling is complete this._checkUnfillState(!backwards); - debuglog("ScrollPanel: "+dir+" fill complete; hasMoreResults:"+hasMoreResults); + debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults); if (hasMoreResults) { // further pagination requests have been disabled until now, so // it's time to check the fill state again in case the pagination // was insufficient. - this.checkFillState(); + return this.checkFillState(depth + 1); } - }).done(); + }); }, /* get the current scroll state. This returns an object with the following @@ -426,7 +459,7 @@ module.exports = React.createClass({ * false, the first token in data-scroll-tokens of the child which we are * tracking. * - * number pixelOffset: undefined if stuckAtBottom is true; if it is false, + * number bottomOffset: undefined if stuckAtBottom is true; if it is false, * the number of pixels the bottom of the tracked child is above the * bottom of the scroll panel. */ @@ -447,14 +480,20 @@ module.exports = React.createClass({ * child list.) */ resetScrollState: function() { - this.scrollState = {stuckAtBottom: this.props.startAtBottom}; + this.scrollState = { + stuckAtBottom: this.props.startAtBottom, + }; + this._bottomGrowth = 0; + this._pages = 0; + this._scrollTimeout = new Timer(100); + this._heightUpdateInProgress = false; }, /** * jump to the top of the content. */ scrollToTop: function() { - this._setScrollTop(0); + this._getScrollNode().scrollTop = 0; this._saveScrollState(); }, @@ -466,24 +505,26 @@ module.exports = React.createClass({ // saved is to do the scroll, then save the updated state. (Calculating // it ourselves is hard, and we can't rely on an onScroll callback // happening, since there may be no user-visible change here). - this._setScrollTop(Number.MAX_VALUE); + const sn = this._getScrollNode(); + sn.scrollTop = sn.scrollHeight; this._saveScrollState(); }, /** * Page up/down. * - * mult: -1 to page up, +1 to page down + * @param {number} mult: -1 to page up, +1 to page down */ scrollRelative: function(mult) { const scrollNode = this._getScrollNode(); const delta = mult * scrollNode.clientHeight * 0.5; - this._setScrollTop(scrollNode.scrollTop + delta); + scrollNode.scrollTop = scrollNode.scrollTop + delta; this._saveScrollState(); }, /** * Scroll up/down in response to a scroll key + * @param {object} ev the keyboard event */ handleScrollKey: function(ev) { switch (ev.keyCode) { @@ -528,77 +569,41 @@ module.exports = React.createClass({ pixelOffset = pixelOffset || 0; offsetBase = offsetBase || 0; - // convert pixelOffset so that it is based on the bottom of the - // container. - pixelOffset += this._getScrollNode().clientHeight * (1-offsetBase); - - // save the desired scroll state. It's important we do this here rather - // than as a result of the scroll event, because (a) we might not *get* - // a scroll event, and (b) it might not currently be possible to set - // the requested scroll state (eg, because we hit the end of the - // timeline and need to do more pagination); we want to save the - // *desired* scroll state rather than what we end up achieving. + // set the trackedScrollToken so we can get the node through _getTrackedNode this.scrollState = { stuckAtBottom: false, trackedScrollToken: scrollToken, - pixelOffset: pixelOffset, }; - - // ... then make it so. - this._restoreSavedScrollState(); - }, - - // set the scrollTop attribute appropriately to position the given child at the - // given offset in the window. A helper for _restoreSavedScrollState. - _scrollToToken: function(scrollToken, pixelOffset) { - /* find the dom node with the right scrolltoken */ - let node; - const messages = this.refs.itemlist.children; - for (let i = messages.length-1; i >= 0; --i) { - const m = messages[i]; - // 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens - // There might only be one scroll token - if (m.dataset.scrollTokens && - m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) { - node = m; - break; - } - } - - if (!node) { - debuglog("ScrollPanel: No node with scrollToken '"+scrollToken+"'"); - return; - } - + const trackedNode = this._getTrackedNode(); const scrollNode = this._getScrollNode(); - const scrollTop = scrollNode.scrollTop; - const viewportBottom = scrollTop + scrollNode.clientHeight; - const nodeBottom = node.offsetTop + node.clientHeight; - const intendedViewportBottom = nodeBottom + pixelOffset; - const scrollDelta = intendedViewportBottom - viewportBottom; - - debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" + - pixelOffset + " (delta: "+scrollDelta+")"); - - if (scrollDelta !== 0) { - this._setScrollTop(scrollTop + scrollDelta); + if (trackedNode) { + // set the scrollTop to the position we want. + // note though, that this might not succeed if the combination of offsetBase and pixelOffset + // would position the trackedNode towards the top of the viewport. + // This because when setting the scrollTop only 10 or so events might be loaded, + // not giving enough content below the trackedNode to scroll downwards + // enough so it ends up in the top of the viewport. + debuglog("scrollToken: setting scrollTop", {offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop}); + scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset; + this._saveScrollState(); } }, _saveScrollState: function() { if (this.props.stickyBottom && this.isAtBottom()) { this.scrollState = { stuckAtBottom: true }; - debuglog("ScrollPanel: Saved scroll state", this.scrollState); + debuglog("saved stuckAtBottom state"); return; } const scrollNode = this._getScrollNode(); - const viewportBottom = scrollNode.scrollTop + scrollNode.clientHeight; + const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight); const itemlist = this.refs.itemlist; const messages = itemlist.children; let node = null; + // TODO: do a binary search here, as items are sorted by offsetTop // loop backwards, from bottom-most message (as that is the most common case) for (let i = messages.length-1; i >= 0; --i) { if (!messages[i].dataset.scrollTokens) { @@ -607,59 +612,150 @@ module.exports = React.createClass({ node = messages[i]; // break at the first message (coming from the bottom) // that has it's offsetTop above the bottom of the viewport. - if (node.offsetTop < viewportBottom) { + if (this._topFromBottom(node) > viewportBottom) { // Use this node as the scrollToken break; } } if (!node) { - debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport"); + debuglog("unable to save scroll state: found no children in the viewport"); return; } - - const nodeBottom = node.offsetTop + node.clientHeight; - debuglog("ScrollPanel: saved scroll state", this.scrollState); + const scrollToken = node.dataset.scrollTokens.split(',')[0]; + debuglog("saving anchored scroll state to message", node && node.innerText, scrollToken); + const bottomOffset = this._topFromBottom(node); this.scrollState = { stuckAtBottom: false, - trackedScrollToken: node.dataset.scrollTokens.split(',')[0], - pixelOffset: viewportBottom - nodeBottom, + trackedNode: node, + trackedScrollToken: scrollToken, + bottomOffset: bottomOffset, + pixelOffset: bottomOffset - viewportBottom, //needed for restoring the scroll position when coming back to the room }; }, - _restoreSavedScrollState: function() { + _restoreSavedScrollState: async function() { const scrollState = this.scrollState; if (scrollState.stuckAtBottom) { - this._setScrollTop(Number.MAX_VALUE); + const sn = this._getScrollNode(); + sn.scrollTop = sn.scrollHeight; } else if (scrollState.trackedScrollToken) { - this._scrollToToken(scrollState.trackedScrollToken, - scrollState.pixelOffset); + const itemlist = this.refs.itemlist; + const trackedNode = this._getTrackedNode(); + if (trackedNode) { + const newBottomOffset = this._topFromBottom(trackedNode); + const bottomDiff = newBottomOffset - scrollState.bottomOffset; + this._bottomGrowth += bottomDiff; + scrollState.bottomOffset = newBottomOffset; + itemlist.style.height = `${this._getListHeight()}px`; + debuglog("balancing height because messages below viewport grew by", bottomDiff); + } + } + if (!this._heightUpdateInProgress) { + this._heightUpdateInProgress = true; + try { + await this._updateHeight(); + } finally { + this._heightUpdateInProgress = false; + } + } else { + debuglog("not updating height because request already in progress"); + } + }, + // need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content? + async _updateHeight() { + // wait until user has stopped scrolling + if (this._scrollTimeout.isRunning()) { + debuglog("updateHeight waiting for scrolling to end ... "); + await this._scrollTimeout.finished(); + } else { + debuglog("updateHeight getting straight to business, no scrolling going on."); + } + + const sn = this._getScrollNode(); + const itemlist = this.refs.itemlist; + const contentHeight = this._getMessagesHeight(); + const minHeight = sn.clientHeight; + const height = Math.max(minHeight, contentHeight); + this._pages = Math.ceil(height / PAGE_SIZE); + this._bottomGrowth = 0; + const newHeight = this._getListHeight(); + + const scrollState = this.scrollState; + if (scrollState.stuckAtBottom) { + itemlist.style.height = `${newHeight}px`; + sn.scrollTop = sn.scrollHeight; + debuglog("updateHeight to", newHeight); + } else if (scrollState.trackedScrollToken) { + const trackedNode = this._getTrackedNode(); + // if the timeline has been reloaded + // this can be called before scrollToBottom or whatever has been called + // so don't do anything if the node has disappeared from + // the currently filled piece of the timeline + if (trackedNode) { + const oldTop = trackedNode.offsetTop; + // changing the height might change the scrollTop + // if the new height is smaller than the scrollTop. + // We calculate the diff that needs to be applied + // ourselves, so be sure to measure the + // scrollTop before changing the height. + const preexistingScrollTop = sn.scrollTop; + itemlist.style.height = `${newHeight}px`; + const newTop = trackedNode.offsetTop; + const topDiff = newTop - oldTop; + sn.scrollTop = preexistingScrollTop + topDiff; + debuglog("updateHeight to", {newHeight, topDiff, preexistingScrollTop}); + } } }, - _setScrollTop: function(scrollTop) { - const scrollNode = this._getScrollNode(); + _getTrackedNode() { + const scrollState = this.scrollState; + const trackedNode = scrollState.trackedNode; - const prevScroll = scrollNode.scrollTop; + if (!trackedNode || !trackedNode.parentElement) { + let node; + const messages = this.refs.itemlist.children; + const scrollToken = scrollState.trackedScrollToken; - // FF ignores attempts to set scrollTop to very large numbers - scrollNode.scrollTop = Math.min(scrollTop, scrollNode.scrollHeight); - - // If this change generates a scroll event, we should not update the - // saved scroll state on it. See the comments in onScroll. - // - // If we *don't* expect a scroll event, we need to leave _lastSetScroll - // alone, otherwise we'll end up ignoring a future scroll event which is - // nothing to do with this change. - - if (scrollNode.scrollTop != prevScroll) { - this._lastSetScroll = scrollNode.scrollTop; + for (let i = messages.length-1; i >= 0; --i) { + const m = messages[i]; + // 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens + // There might only be one scroll token + if (m.dataset.scrollTokens && + m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) { + node = m; + break; + } + } + if (node) { + debuglog("had to find tracked node again for " + scrollState.trackedScrollToken); + } + scrollState.trackedNode = node; } - debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop, - "requested:", scrollTop, - "_lastSetScroll:", this._lastSetScroll); + if (!scrollState.trackedNode) { + debuglog("No node with ; '"+scrollState.trackedScrollToken+"'"); + return; + } + + return scrollState.trackedNode; + }, + + _getListHeight() { + return this._bottomGrowth + (this._pages * PAGE_SIZE); + }, + + _getMessagesHeight() { + const itemlist = this.refs.itemlist; + const lastNode = itemlist.lastElementChild; + // 18 is itemlist padding + return (lastNode.offsetTop + lastNode.clientHeight) - itemlist.firstElementChild.offsetTop + (18 * 2); + }, + + _topFromBottom(node) { + return this.refs.itemlist.clientHeight - node.offsetTop; }, /* get the DOM node which has the scrollTop property we care about for our @@ -672,71 +768,112 @@ module.exports = React.createClass({ throw new Error("ScrollPanel._getScrollNode called when unmounted"); } - if (!this._gemScroll) { + if (!this._divScroll) { // Likewise, we should have the ref by this point, but if not // turn the NPE into something meaningful. throw new Error("ScrollPanel._getScrollNode called before gemini ref collected"); } - return this._gemScroll.scrollbar.getViewElement(); + return this._divScroll; }, - _collectGeminiScroll: function(gemScroll) { - this._gemScroll = gemScroll; + _collectScroll: function(divScroll) { + this._divScroll = divScroll; }, /** - * Set the current height as the min height for the message list - * so the timeline cannot shrink. This is used to avoid - * jumping when the typing indicator gets replaced by a smaller message. - */ - blockShrinking: function() { + Mark the bottom offset of the last tile so we can balance it out when + anything below it changes, by calling updatePreventShrinking, to keep + the same minimum bottom offset, effectively preventing the timeline to shrink. + */ + preventShrinking: function() { const messageList = this.refs.itemlist; - if (messageList) { - const currentHeight = messageList.clientHeight; - messageList.style.minHeight = `${currentHeight}px`; + const tiles = messageList && messageList.children; + if (!messageList) { + return; } + let lastTileNode; + for (let i = tiles.length - 1; i >= 0; i--) { + const node = tiles[i]; + if (node.dataset.scrollTokens) { + lastTileNode = node; + break; + } + } + if (!lastTileNode) { + return; + } + this.clearPreventShrinking(); + const offsetFromBottom = messageList.clientHeight - (lastTileNode.offsetTop + lastTileNode.clientHeight); + this.preventShrinkingState = { + offsetFromBottom: offsetFromBottom, + offsetNode: lastTileNode, + }; + debuglog("prevent shrinking, last tile ", offsetFromBottom, "px from bottom"); + }, + + /** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */ + clearPreventShrinking: function() { + const messageList = this.refs.itemlist; + const balanceElement = messageList && messageList.parentElement; + if (balanceElement) balanceElement.style.paddingBottom = null; + this.preventShrinkingState = null; + debuglog("prevent shrinking cleared"); }, /** - * Clear the previously set min height - */ - clearBlockShrinking: function() { - const messageList = this.refs.itemlist; - if (messageList) { - messageList.style.minHeight = null; - } - }, - - _checkBlockShrinking: function() { - const sn = this._getScrollNode(); - const scrollState = this.scrollState; - if (!scrollState.stuckAtBottom) { - const spaceBelowViewport = sn.scrollHeight - (sn.scrollTop + sn.clientHeight); - // only if we've scrolled up 200px from the bottom - // should we clear the min-height used by the typing notifications, - // otherwise we might still see it jump as the whitespace disappears - // when scrolling up from the bottom - if (spaceBelowViewport >= 200) { - this.clearBlockShrinking(); + update the container padding to balance + the bottom offset of the last tile since + preventShrinking was called. + Clears the prevent-shrinking state ones the offset + from the bottom of the marked tile grows larger than + what it was when marking. + */ + updatePreventShrinking: function() { + if (this.preventShrinkingState) { + const sn = this._getScrollNode(); + const scrollState = this.scrollState; + const messageList = this.refs.itemlist; + const {offsetNode, offsetFromBottom} = this.preventShrinkingState; + // element used to set paddingBottom to balance the typing notifs disappearing + const balanceElement = messageList.parentElement; + // if the offsetNode got unmounted, clear + let shouldClear = !offsetNode.parentElement; + // also if 200px from bottom + if (!shouldClear && !scrollState.stuckAtBottom) { + const spaceBelowViewport = sn.scrollHeight - (sn.scrollTop + sn.clientHeight); + shouldClear = spaceBelowViewport >= 200; + } + // try updating if not clearing + if (!shouldClear) { + const currentOffset = messageList.clientHeight - (offsetNode.offsetTop + offsetNode.clientHeight); + const offsetDiff = offsetFromBottom - currentOffset; + if (offsetDiff > 0) { + balanceElement.style.paddingBottom = `${offsetDiff}px`; + debuglog("update prevent shrinking ", offsetDiff, "px from bottom"); + } else if (offsetDiff < 0) { + shouldClear = true; + } + } + if (shouldClear) { + this.clearPreventShrinking(); } } }, render: function() { - const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); // TODO: the classnames on the div and ol could do with being updated to // reflect the fact that we don't necessarily contain a list of messages. // it's not obvious why we have a separate div and ol anyway. - return ( + return (
      { this.props.children }
    -
    - ); + + ); }, }); diff --git a/src/components/structures/TagPanelButtons.js b/src/components/structures/TagPanelButtons.js index e976fdd436..bbd9d28576 100644 --- a/src/components/structures/TagPanelButtons.js +++ b/src/components/structures/TagPanelButtons.js @@ -39,7 +39,7 @@ const TagPanelButtons = React.createClass({ if (payload.action === "show_redesign_feedback_dialog") { const RedesignFeedbackDialog = sdk.getComponent("views.dialogs.RedesignFeedbackDialog"); - Modal.createDialog(RedesignFeedbackDialog); + Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog); } }, diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index f0feaf94c5..aa278f2349 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -44,11 +44,10 @@ const READ_RECEIPT_INTERVAL_MS = 500; const DEBUG = false; +let debuglog = function() {}; if (DEBUG) { // using bind means that we get to keep useful line numbers in the console - var debuglog = console.log.bind(console); -} else { - var debuglog = function() {}; + debuglog = console.log.bind(console); } /* @@ -56,7 +55,7 @@ if (DEBUG) { * * Also responsible for handling and sending read receipts. */ -var TimelinePanel = React.createClass({ +const TimelinePanel = React.createClass({ displayName: 'TimelinePanel', propTypes: { @@ -445,6 +444,7 @@ var TimelinePanel = React.createClass({ const updatedState = {events: events}; + let callRMUpdated; if (this.props.manageReadMarkers) { // when a new event arrives when the user is not watching the // window, but the window is in its auto-scroll mode, make sure the @@ -456,7 +456,7 @@ var TimelinePanel = React.createClass({ // const myUserId = MatrixClientPeg.get().credentials.userId; const sender = ev.sender ? ev.sender.userId : null; - var callRMUpdated = false; + callRMUpdated = false; if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) { updatedState.readMarkerVisible = true; } else if (lastEv && this.getReadMarkerPosition() === 0) { @@ -566,7 +566,7 @@ var TimelinePanel = React.createClass({ UserActivity.sharedInstance().timeWhileActiveRecently(this._readMarkerActivityTimer); try { await this._readMarkerActivityTimer.finished(); - } catch(e) { continue; /* aborted */ } + } catch (e) { continue; /* aborted */ } // outside of try/catch to not swallow errors this.updateReadMarker(); } @@ -578,7 +578,7 @@ var TimelinePanel = React.createClass({ UserActivity.sharedInstance().timeWhileActiveNow(this._readReceiptActivityTimer); try { await this._readReceiptActivityTimer.finished(); - } catch(e) { continue; /* aborted */ } + } catch (e) { continue; /* aborted */ } // outside of try/catch to not swallow errors this.sendReadReceipt(); } @@ -732,7 +732,8 @@ var TimelinePanel = React.createClass({ const events = this._timelineWindow.getEvents(); // first find where the current RM is - for (var i = 0; i < events.length; i++) { + let i; + for (i = 0; i < events.length; i++) { if (events[i].getId() == this.state.readMarkerEventId) { break; } @@ -744,7 +745,7 @@ var TimelinePanel = React.createClass({ // now think about advancing it const myUserId = MatrixClientPeg.get().credentials.userId; for (i++; i < events.length; i++) { - var ev = events[i]; + const ev = events[i]; if (!ev.sender || ev.sender.userId != myUserId) { break; } @@ -752,7 +753,7 @@ var TimelinePanel = React.createClass({ // i is now the first unread message which we didn't send ourselves. i--; - var ev = events[i]; + const ev = events[i]; this._setReadMarker(ev.getId(), ev.getTs()); }, @@ -882,7 +883,7 @@ var TimelinePanel = React.createClass({ return ret; }, - /** + /* * called by the parent component when PageUp/Down/etc is pressed. * * We pass it down to the scroll panel. @@ -939,7 +940,7 @@ var TimelinePanel = React.createClass({ // clear the timeline min-height when // (re)loading the timeline if (this.refs.messagePanel) { - this.refs.messagePanel.clearTimelineHeight(); + this.refs.messagePanel.onTimelineReset(); } this._reloadEvents(); @@ -975,11 +976,10 @@ var TimelinePanel = React.createClass({ }; const onError = (error) => { - this.setState({timelineLoading: false}); + this.setState({ timelineLoading: false }); console.error( `Error loading timeline panel at ${eventId}: ${error}`, ); - const msg = error.message ? error.message : JSON.stringify(error); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); let onFinished; @@ -997,9 +997,18 @@ var TimelinePanel = React.createClass({ }); }; } - const message = (error.errcode == 'M_FORBIDDEN') - ? _t("Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.") - : _t("Tried to load a specific point in this room's timeline, but was unable to find it."); + let message; + if (error.errcode == 'M_FORBIDDEN') { + message = _t( + "Tried to load a specific point in this room's timeline, but you " + + "do not have permission to view the message in question.", + ); + } else { + message = _t( + "Tried to load a specific point in this room's timeline, but was " + + "unable to find it.", + ); + } Modal.createTrackedDialog('Failed to load timeline position', '', ErrorDialog, { title: _t("Failed to load timeline position"), description: message, @@ -1104,12 +1113,13 @@ var TimelinePanel = React.createClass({ }, /** - * get the id of the event corresponding to our user's latest read-receipt. + * Get the id of the event corresponding to our user's latest read-receipt. * * @param {Boolean} ignoreSynthesized If true, return only receipts that * have been sent by the server, not * implicit ones generated by the JS * SDK. + * @return {String} the event ID */ _getCurrentReadReceipt: function(ignoreSynthesized) { const client = MatrixClientPeg.get(); @@ -1228,6 +1238,7 @@ var TimelinePanel = React.createClass({ alwaysShowTimestamps={this.state.alwaysShowTimestamps} className={this.props.className} tileShape={this.props.tileShape} + resizeNotifier={this.props.resizeNotifier} /> ); }, diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index b54ea00c16..6f26f0af35 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -16,7 +16,7 @@ limitations under the License. const React = require('react'); import PropTypes from 'prop-types'; -const ContentMessages = require('../../ContentMessages'); +import ContentMessages from '../../ContentMessages'; const dis = require('../../dispatcher'); const filesize = require('filesize'); import { _t } from '../../languageHandler'; @@ -40,6 +40,7 @@ module.exports = React.createClass({displayName: 'UploadBar', switch (payload.action) { case 'upload_progress': case 'upload_finished': + case 'upload_canceled': case 'upload_failed': if (this.mounted) this.forceUpdate(); break; @@ -47,7 +48,7 @@ module.exports = React.createClass({displayName: 'UploadBar', }, render: function() { - const uploads = ContentMessages.getCurrentUploads(); + const uploads = ContentMessages.sharedInstance().getCurrentUploads(); // for testing UI... - also fix up the ContentMessages.getCurrentUploads().length // check in RoomView @@ -93,7 +94,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
    { uploadedSize } / { totalSize } diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.js index 8c7bf50bcf..46071f0a9c 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.js @@ -222,7 +222,7 @@ module.exports = React.createClass({ } let yourMatrixAccountText = _t('Your Matrix account'); - if (this.state.enteredHsUrl === this.props.defaultHsUrl) { + if (this.state.enteredHsUrl === this.props.defaultHsUrl && this.props.defaultServerName) { yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', { serverName: this.props.defaultServerName, }); diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.js index 4e3048483b..2940346a4f 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.js @@ -42,7 +42,12 @@ const PHASES_ENABLED = true; // These are used in several places, and come from the js-sdk's autodiscovery // stuff. We define them here so that they'll be picked up by i18n. _td("Invalid homeserver discovery response"); +_td("Failed to get autodiscovery configuration from server"); +_td("Invalid base_url for m.homeserver"); +_td("Homeserver URL does not appear to be a valid Matrix homeserver"); _td("Invalid identity server discovery response"); +_td("Invalid base_url for m.identity_server"); +_td("Identity server URL does not appear to be a valid identity server"); _td("General failure"); /** @@ -54,8 +59,6 @@ module.exports = React.createClass({ propTypes: { onLoggedIn: PropTypes.func.isRequired, - enableGuest: PropTypes.bool, - // 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 @@ -225,37 +228,6 @@ module.exports = React.createClass({ }).done(); }, - _onLoginAsGuestClick: function(ev) { - ev.preventDefault(); - ev.stopPropagation(); - - const self = this; - self.setState({ - busy: true, - errorText: null, - loginIncorrect: false, - }); - - this._loginLogic.loginAsGuest().then(function(data) { - self.props.onLoggedIn(data); - }, function(error) { - let errorText; - if (error.httpStatus === 403) { - errorText = _t("Guest access is disabled on this homeserver."); - } else { - errorText = self._errorTextFromError(error); - } - self.setState({ - errorText: errorText, - loginIncorrect: false, - }); - }).finally(function() { - self.setState({ - busy: false, - }); - }).done(); - }, - onUsernameChanged: function(username) { this.setState({ username: username }); }, @@ -627,14 +599,6 @@ module.exports = React.createClass({ const errorText = this.props.defaultServerDiscoveryError || this.state.discoveryError || this.state.errorText; - let loginAsGuestJsx; - if (this.props.enableGuest) { - loginAsGuestJsx = - - { _t('Try the app first') } - ; - } - let errorTextSection; if (errorText) { errorTextSection = ( @@ -658,7 +622,6 @@ module.exports = React.createClass({ { _t('Create account') } - { loginAsGuestJsx } ); diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.js index 0d36e592f8..708118bb22 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.js @@ -28,8 +28,6 @@ import SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; import * as ServerType from '../../views/auth/ServerTypeSelector'; -const MIN_PASSWORD_LENGTH = 6; - // Phases // Show controls to configure server details const PHASE_SERVER_DETAILS = 0; @@ -60,7 +58,6 @@ module.exports = React.createClass({ customIsUrl: PropTypes.string, defaultHsUrl: PropTypes.string, defaultIsUrl: PropTypes.string, - skipServerDetails: PropTypes.bool, brand: PropTypes.string, email: PropTypes.string, // registration shouldn't know or care how login is done. @@ -71,26 +68,6 @@ module.exports = React.createClass({ getInitialState: function() { const serverType = ServerType.getTypeFromHsUrl(this.props.customHsUrl); - const customURLsAllowed = !SdkConfig.get()['disable_custom_urls']; - let initialPhase = this.getDefaultPhaseForServerType(serverType); - if ( - // if we have these two, skip to the good bit - // (they could come in from the URL params in a - // registration email link) - (this.props.clientSecret && this.props.sessionId) || - // if custom URLs aren't allowed, skip to form - !customURLsAllowed || - // if other logic says to, skip to form - this.props.skipServerDetails - ) { - // TODO: It would seem we've now added enough conditions here that the initial - // phase will _always_ be the form. It's tempting to remove the complexity and - // just do that, but we keep tweaking and changing auth, so let's wait until - // things settle a bit. - // Filed https://github.com/vector-im/riot-web/issues/8886 to track this. - initialPhase = PHASE_REGISTRATION; - } - return { busy: false, errorText: null, @@ -113,7 +90,7 @@ module.exports = React.createClass({ hsUrl: this.props.customHsUrl, isUrl: this.props.customIsUrl, // Phase of the overall registration dialog. - phase: initialPhase, + phase: PHASE_REGISTRATION, flows: null, }; }, @@ -308,58 +285,6 @@ module.exports = React.createClass({ }); }, - onFormValidationChange: function(fieldErrors) { - // `fieldErrors` is an object mapping field IDs to error codes when there is an - // error or `null` for no error, so the values array will be something like: - // `[ null, "RegistrationForm.ERR_PASSWORD_MISSING", null]` - // Find the first non-null error code and show that. - const errCode = Object.values(fieldErrors).find(value => !!value); - if (!errCode) { - this.setState({ - errorText: null, - }); - return; - } - - let errMsg; - switch (errCode) { - case "RegistrationForm.ERR_PASSWORD_MISSING": - errMsg = _t('Missing password.'); - break; - case "RegistrationForm.ERR_PASSWORD_MISMATCH": - errMsg = _t('Passwords don\'t match.'); - break; - case "RegistrationForm.ERR_PASSWORD_LENGTH": - errMsg = _t('Password too short (min %(MIN_PASSWORD_LENGTH)s).', {MIN_PASSWORD_LENGTH}); - break; - case "RegistrationForm.ERR_EMAIL_INVALID": - errMsg = _t('This doesn\'t look like a valid email address.'); - break; - case "RegistrationForm.ERR_PHONE_NUMBER_INVALID": - errMsg = _t('This doesn\'t look like a valid phone number.'); - break; - case "RegistrationForm.ERR_MISSING_EMAIL": - errMsg = _t('An email address is required to register on this homeserver.'); - break; - case "RegistrationForm.ERR_MISSING_PHONE_NUMBER": - errMsg = _t('A phone number is required to register on this homeserver.'); - break; - case "RegistrationForm.ERR_USERNAME_INVALID": - errMsg = _t("A username can only contain lower case letters, numbers and '=_-./'"); - break; - case "RegistrationForm.ERR_USERNAME_BLANK": - errMsg = _t('You need to enter a username.'); - break; - default: - console.error("Unknown error code: %s", errCode); - errMsg = _t('An unknown error occurred.'); - break; - } - this.setState({ - errorText: errMsg, - }); - }, - onLoginClick: function(ev) { ev.preventDefault(); ev.stopPropagation(); @@ -534,8 +459,6 @@ module.exports = React.createClass({ defaultPhoneCountry={this.state.formVals.phoneCountry} defaultPhoneNumber={this.state.formVals.phoneNumber} defaultPassword={this.state.formVals.password} - minPasswordLength={MIN_PASSWORD_LENGTH} - onValidationChange={this.onFormValidationChange} onRegisterClick={this.onFormSubmit} onEditServerDetailsClick={onEditServerDetailsClick} flows={this.state.flows} diff --git a/src/components/views/auth/AuthButtons.js b/src/components/views/auth/AuthButtons.js deleted file mode 100644 index 35bfabbbca..0000000000 --- a/src/components/views/auth/AuthButtons.js +++ /dev/null @@ -1,57 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd -Copyright 2018, 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. -*/ - -'use strict'; - -const React = require('react'); -import { _t } from '../../../languageHandler'; -const dis = require('../../../dispatcher'); -const AccessibleButton = require('../elements/AccessibleButton'); - -module.exports = React.createClass({ - displayName: 'AuthButtons', - - propTypes: { - }, - - onLoginClick: function() { - dis.dispatch({ action: 'start_login' }); - }, - - onRegisterClick: function() { - dis.dispatch({ action: 'start_registration' }); - }, - - render: function() { - const loginButton = ( -
    - - { _t("Login") } - - - { _t("Register") } - -
    - ); - - return ( -
    - { loginButton } -
    - ); - }, -}); diff --git a/src/components/views/auth/RegistrationForm.js b/src/components/views/auth/RegistrationForm.js index 1784ab61c3..7c083ea270 100644 --- a/src/components/views/auth/RegistrationForm.js +++ b/src/components/views/auth/RegistrationForm.js @@ -25,6 +25,7 @@ import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import { SAFE_LOCALPART_REGEX } from '../../../Registration'; +import withValidation from '../elements/Validation'; const FIELD_EMAIL = 'field_email'; const FIELD_PHONE_NUMBER = 'field_phone_number'; @@ -32,6 +33,8 @@ const FIELD_USERNAME = 'field_username'; const FIELD_PASSWORD = 'field_password'; const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; +const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. + /** * A pure UI component which displays a registration form. */ @@ -45,8 +48,6 @@ module.exports = React.createClass({ defaultPhoneNumber: PropTypes.string, defaultUsername: PropTypes.string, defaultPassword: PropTypes.string, - minPasswordLength: PropTypes.number, - onValidationChange: PropTypes.func, onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise onEditServerDetailsClick: PropTypes.func, flows: PropTypes.arrayOf(PropTypes.object).isRequired, @@ -59,7 +60,6 @@ module.exports = React.createClass({ getDefaultProps: function() { return { - minPasswordLength: 6, onValidationChange: console.error, }; }, @@ -67,7 +67,7 @@ module.exports = React.createClass({ getInitialState: function() { return { // Field error codes by field ID - fieldErrors: {}, + fieldValid: {}, // The ISO2 country code selected in the phone number entry phoneCountry: this.props.defaultPhoneCountry, username: "", @@ -75,44 +75,37 @@ module.exports = React.createClass({ phoneNumber: "", password: "", passwordConfirm: "", + passwordComplexity: null, }; }, - onSubmit: function(ev) { + onSubmit: async function(ev) { ev.preventDefault(); - // validate everything, in reverse order so - // the error that ends up being displayed - // is the one from the first invalid field. - // It's not super ideal that this just calls - // onValidationChange once for each invalid field. - this.validateField(FIELD_PHONE_NUMBER, ev.type); - this.validateField(FIELD_EMAIL, ev.type); - this.validateField(FIELD_PASSWORD_CONFIRM, ev.type); - this.validateField(FIELD_PASSWORD, ev.type); - this.validateField(FIELD_USERNAME, ev.type); + const allFieldsValid = await this.verifyFieldsBeforeSubmit(); + if (!allFieldsValid) { + return; + } const self = this; - if (this.allFieldsValid()) { - if (this.state.email == '') { - 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?") } -
    , - button: _t("Continue"), - onFinished: function(confirmed) { - if (confirmed) { - self._doSubmit(ev); - } - }, - }); - } else { - self._doSubmit(ev); - } + if (this.state.email == '') { + 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?") } +
    , + button: _t("Continue"), + onFinished: function(confirmed) { + if (confirmed) { + self._doSubmit(ev); + } + }, + }); + } else { + self._doSubmit(ev); } }, @@ -134,118 +127,81 @@ module.exports = React.createClass({ } }, + async verifyFieldsBeforeSubmit() { + // Blur the active element if any, so we first run its blur validation, + // which is less strict than the pass we're about to do below for all fields. + const activeElement = document.activeElement; + if (activeElement) { + activeElement.blur(); + } + + const fieldIDsInDisplayOrder = [ + FIELD_USERNAME, + FIELD_PASSWORD, + FIELD_PASSWORD_CONFIRM, + FIELD_EMAIL, + FIELD_PHONE_NUMBER, + ]; + + // Run all fields with stricter validation that no longer allows empty + // values for required fields. + for (const fieldID of fieldIDsInDisplayOrder) { + const field = this[fieldID]; + if (!field) { + continue; + } + field.validate({ allowEmpty: false }); + } + + // Validation and state updates are async, so we need to wait for them to complete + // first. Queue a `setState` callback and wait for it to resolve. + await new Promise(resolve => this.setState({}, resolve)); + + if (this.allFieldsValid()) { + return true; + } + + const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder); + + if (!invalidField) { + return true; + } + + // Focus the first invalid field and show feedback in the stricter mode + // that no longer allows empty values for required fields. + invalidField.focus(); + invalidField.validate({ allowEmpty: false, focused: true }); + return false; + }, + /** * @returns {boolean} true if all fields were valid last time they were validated. */ allFieldsValid: function() { - const keys = Object.keys(this.state.fieldErrors); + const keys = Object.keys(this.state.fieldValid); for (let i = 0; i < keys.length; ++i) { - if (this.state.fieldErrors[keys[i]]) { + if (!this.state.fieldValid[keys[i]]) { return false; } } return true; }, - validateField: function(fieldID, eventType) { - const pwd1 = this.state.password.trim(); - const pwd2 = this.state.passwordConfirm.trim(); - const allowEmpty = eventType === "blur"; - - switch (fieldID) { - case FIELD_EMAIL: { - const email = this.state.email; - const emailValid = email === '' || Email.looksValid(email); - if (this._authStepIsRequired('m.login.email.identity') && (!emailValid || email === '')) { - this.markFieldValid(fieldID, false, "RegistrationForm.ERR_MISSING_EMAIL"); - } else this.markFieldValid(fieldID, emailValid, "RegistrationForm.ERR_EMAIL_INVALID"); - break; + findFirstInvalidField(fieldIDs) { + for (const fieldID of fieldIDs) { + if (!this.state.fieldValid[fieldID] && this[fieldID]) { + return this[fieldID]; } - case FIELD_PHONE_NUMBER: { - const phoneNumber = this.state.phoneNumber; - const phoneNumberValid = phoneNumber === '' || phoneNumberLooksValid(phoneNumber); - if (this._authStepIsRequired('m.login.msisdn') && (!phoneNumberValid || phoneNumber === '')) { - this.markFieldValid(fieldID, false, "RegistrationForm.ERR_MISSING_PHONE_NUMBER"); - } else this.markFieldValid(fieldID, phoneNumberValid, "RegistrationForm.ERR_PHONE_NUMBER_INVALID"); - break; - } - case FIELD_USERNAME: { - const username = this.state.username; - if (allowEmpty && username === '') { - this.markFieldValid(fieldID, true); - } else if (!SAFE_LOCALPART_REGEX.test(username)) { - this.markFieldValid( - fieldID, - false, - "RegistrationForm.ERR_USERNAME_INVALID", - ); - } else if (username == '') { - this.markFieldValid( - fieldID, - false, - "RegistrationForm.ERR_USERNAME_BLANK", - ); - } else { - this.markFieldValid(fieldID, true); - } - break; - } - case FIELD_PASSWORD: - if (allowEmpty && pwd1 === "") { - this.markFieldValid(fieldID, true); - } else if (pwd1 == '') { - this.markFieldValid( - fieldID, - false, - "RegistrationForm.ERR_PASSWORD_MISSING", - ); - } else if (pwd1.length < this.props.minPasswordLength) { - this.markFieldValid( - fieldID, - false, - "RegistrationForm.ERR_PASSWORD_LENGTH", - ); - } else { - this.markFieldValid(fieldID, true); - } - break; - case FIELD_PASSWORD_CONFIRM: - if (allowEmpty && pwd2 === "") { - this.markFieldValid(fieldID, true); - } else { - this.markFieldValid( - fieldID, pwd1 == pwd2, - "RegistrationForm.ERR_PASSWORD_MISMATCH", - ); - } - break; } + return null; }, - markFieldValid: function(fieldID, valid, errorCode) { - const { fieldErrors } = this.state; - if (valid) { - fieldErrors[fieldID] = null; - } else { - fieldErrors[fieldID] = errorCode; - } + markFieldValid: function(fieldID, valid) { + const { fieldValid } = this.state; + fieldValid[fieldID] = valid; this.setState({ - fieldErrors, + fieldValid, }); - this.props.onValidationChange(fieldErrors); - }, - - _classForField: function(fieldID, ...baseClasses) { - let cls = baseClasses.join(' '); - if (this.state.fieldErrors[fieldID]) { - if (cls) cls += ' '; - cls += 'error'; - } - return cls; - }, - - onEmailBlur(ev) { - this.validateField(FIELD_EMAIL, ev.type); }, onEmailChange(ev) { @@ -254,26 +210,113 @@ module.exports = React.createClass({ }); }, - onPasswordBlur(ev) { - this.validateField(FIELD_PASSWORD, ev.type); + async onEmailValidate(fieldState) { + const result = await this.validateEmailRules(fieldState); + this.markFieldValid(FIELD_EMAIL, result.valid); + return result; }, + validateEmailRules: withValidation({ + description: () => _t("Use an email address to recover your account"), + rules: [ + { + key: "required", + test: function({ value, allowEmpty }) { + return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value; + }, + invalid: () => _t("Enter email address (required on this homeserver)"), + }, + { + key: "email", + test: ({ value }) => !value || Email.looksValid(value), + invalid: () => _t("Doesn't look like a valid email address"), + }, + ], + }), + onPasswordChange(ev) { this.setState({ password: ev.target.value, }); }, - onPasswordConfirmBlur(ev) { - this.validateField(FIELD_PASSWORD_CONFIRM, ev.type); + async onPasswordValidate(fieldState) { + const result = await this.validatePasswordRules(fieldState); + this.markFieldValid(FIELD_PASSWORD, result.valid); + return result; }, + validatePasswordRules: withValidation({ + description: function() { + const complexity = this.state.passwordComplexity; + const score = complexity ? complexity.score : 0; + return ; + }, + rules: [ + { + key: "required", + test: ({ value, allowEmpty }) => allowEmpty || !!value, + invalid: () => _t("Enter password"), + }, + { + key: "complexity", + test: async function({ value }) { + if (!value) { + return false; + } + const { scorePassword } = await import('../../../utils/PasswordScorer'); + const complexity = scorePassword(value); + this.setState({ + passwordComplexity: complexity, + }); + return complexity.score >= PASSWORD_MIN_SCORE; + }, + valid: () => _t("Nice, strong password!"), + invalid: function() { + const complexity = this.state.passwordComplexity; + if (!complexity) { + return null; + } + const { feedback } = complexity; + return feedback.warning || feedback.suggestions[0] || _t("Keep going..."); + }, + }, + ], + }), + onPasswordConfirmChange(ev) { this.setState({ passwordConfirm: ev.target.value, }); }, + async onPasswordConfirmValidate(fieldState) { + const result = await this.validatePasswordConfirmRules(fieldState); + this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid); + return result; + }, + + validatePasswordConfirmRules: withValidation({ + rules: [ + { + key: "required", + test: ({ value, allowEmpty }) => allowEmpty || !!value, + invalid: () => _t("Confirm password"), + }, + { + key: "match", + test: function({ value }) { + return !value || value === this.state.password; + }, + invalid: () => _t("Passwords don't match"), + }, + ], + }), + onPhoneCountryChange(newVal) { this.setState({ phoneCountry: newVal.iso2, @@ -281,26 +324,64 @@ module.exports = React.createClass({ }); }, - onPhoneNumberBlur(ev) { - this.validateField(FIELD_PHONE_NUMBER, ev.type); - }, - onPhoneNumberChange(ev) { this.setState({ phoneNumber: ev.target.value, }); }, - onUsernameBlur(ev) { - this.validateField(FIELD_USERNAME, ev.type); + async onPhoneNumberValidate(fieldState) { + const result = await this.validatePhoneNumberRules(fieldState); + this.markFieldValid(FIELD_PHONE_NUMBER, result.valid); + return result; }, + validatePhoneNumberRules: withValidation({ + description: () => _t("Other users can invite you to rooms using your contact details"), + rules: [ + { + key: "required", + test: function({ value, allowEmpty }) { + return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value; + }, + invalid: () => _t("Enter phone number (required on this homeserver)"), + }, + { + key: "email", + test: ({ value }) => !value || phoneNumberLooksValid(value), + invalid: () => _t("Doesn't look like a valid phone number"), + }, + ], + }), + onUsernameChange(ev) { this.setState({ username: ev.target.value, }); }, + async onUsernameValidate(fieldState) { + const result = await this.validateUsernameRules(fieldState); + this.markFieldValid(FIELD_USERNAME, result.valid); + return result; + }, + + validateUsernameRules: withValidation({ + description: () => _t("Use letters, numbers, dashes and underscores only"), + rules: [ + { + key: "required", + test: ({ value, allowEmpty }) => allowEmpty || !!value, + invalid: () => _t("Enter username"), + }, + { + key: "safeLocalpart", + test: ({ value }) => !value || SAFE_LOCALPART_REGEX.test(value), + invalid: () => _t("Some characters not allowed"), + }, + ], + }), + /** * A step is required if all flows include that step. * @@ -325,9 +406,99 @@ module.exports = React.createClass({ }); }, - render: function() { + renderEmail() { + if (!this._authStepIsUsed('m.login.email.identity')) { + return null; + } const Field = sdk.getComponent('elements.Field'); + const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ? + _t("Email") : + _t("Email (optional)"); + return this[FIELD_EMAIL] = field} + type="text" + label={emailPlaceholder} + defaultValue={this.props.defaultEmail} + value={this.state.email} + onChange={this.onEmailChange} + onValidate={this.onEmailValidate} + />; + }, + renderPassword() { + const Field = sdk.getComponent('elements.Field'); + return this[FIELD_PASSWORD] = field} + type="password" + label={_t("Password")} + defaultValue={this.props.defaultPassword} + value={this.state.password} + onChange={this.onPasswordChange} + onValidate={this.onPasswordValidate} + />; + }, + + renderPasswordConfirm() { + const Field = sdk.getComponent('elements.Field'); + return this[FIELD_PASSWORD_CONFIRM] = field} + type="password" + label={_t("Confirm")} + defaultValue={this.props.defaultPassword} + value={this.state.passwordConfirm} + onChange={this.onPasswordConfirmChange} + onValidate={this.onPasswordConfirmValidate} + />; + }, + + renderPhoneNumber() { + const threePidLogin = !SdkConfig.get().disable_3pid_login; + if (!threePidLogin || !this._authStepIsUsed('m.login.msisdn')) { + return null; + } + const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); + const Field = sdk.getComponent('elements.Field'); + const phoneLabel = this._authStepIsRequired('m.login.msisdn') ? + _t("Phone") : + _t("Phone (optional)"); + const phoneCountry = ; + return this[FIELD_PHONE_NUMBER] = field} + type="text" + label={phoneLabel} + defaultValue={this.props.defaultPhoneNumber} + value={this.state.phoneNumber} + prefix={phoneCountry} + onChange={this.onPhoneNumberChange} + onValidate={this.onPhoneNumberValidate} + />; + }, + + renderUsername() { + const Field = sdk.getComponent('elements.Field'); + return this[FIELD_USERNAME] = field} + type="text" + autoFocus={true} + label={_t("Username")} + defaultValue={this.props.defaultUsername} + value={this.state.username} + onChange={this.onUsernameChange} + onValidate={this.onUsernameValidate} + />; + }, + + render: function() { let yourMatrixAccountText = _t('Create your Matrix account'); if (this.props.hsName) { yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { @@ -353,53 +524,6 @@ module.exports = React.createClass({ ; } - let emailSection; - if (this._authStepIsUsed('m.login.email.identity')) { - const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ? - _t("Email") : - _t("Email (optional)"); - - emailSection = ( - - ); - } - - const threePidLogin = !SdkConfig.get().disable_3pid_login; - const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); - let phoneSection; - if (threePidLogin && this._authStepIsUsed('m.login.msisdn')) { - const phoneLabel = this._authStepIsRequired('m.login.msisdn') ? - _t("Phone") : - _t("Phone (optional)"); - const phoneCountry = ; - - phoneSection = ; - } - const registerButton = ( ); @@ -412,48 +536,18 @@ module.exports = React.createClass({
    - + {this.renderUsername()}
    - - + {this.renderPassword()} + {this.renderPasswordConfirm()}
    - { emailSection } - { phoneSection } + {this.renderEmail()} + {this.renderPhoneNumber()}
    - {_t( - "Use an email address to recover your account. Other users " + - "can invite you to rooms using your contact details.", - )} + {_t("Use an email address to recover your account.") + " "} + {_t("Other users can invite you to rooms using your contact details.")} { registerButton }
    diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 504729f629..2e4611f7d0 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -27,6 +27,7 @@ import Modal from '../../../Modal'; import Resend from '../../../Resend'; import SettingsStore from '../../../settings/SettingsStore'; import { isUrlPermitted } from '../../../HtmlUtils'; +import { isContentActionable } from '../../../utils/EventUtils'; module.exports = React.createClass({ displayName: 'MessageContextMenu', @@ -201,14 +202,6 @@ module.exports = React.createClass({ this.closeMenu(); }, - onReplyClick: function() { - dis.dispatch({ - action: 'reply_to_event', - event: this.props.mxEvent, - }); - this.closeMenu(); - }, - onCollapseReplyThreadClick: function() { this.props.collapseReplyThread(); this.closeMenu(); @@ -226,7 +219,6 @@ module.exports = React.createClass({ let unhidePreviewButton; let externalURLButton; let quoteButton; - let replyButton; let collapseReplyThread; // status is SENT before remote-echo, null after @@ -256,28 +248,19 @@ module.exports = React.createClass({ ); } - if (isSent && mxEvent.getType() === 'm.room.message') { - const content = mxEvent.getContent(); - if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) { - forwardButton = ( -
    - { _t('Forward Message') } + if (isContentActionable(mxEvent)) { + forwardButton = ( +
    + { _t('Forward Message') } +
    + ); + + if (this.state.canPin) { + pinButton = ( +
    + { this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
    ); - - replyButton = ( -
    - { _t('Reply') } -
    - ); - - if (this.state.canPin) { - pinButton = ( -
    - { this._isPinned() ? _t('Unpin Message') : _t('Pin Message') } -
    - ); - } } } @@ -368,7 +351,6 @@ module.exports = React.createClass({ { unhidePreviewButton } { permalinkButton } { quoteButton } - { replyButton } { externalURLButton } { collapseReplyThread } { e2eInfo } diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 6276a45839..67fd197f8a 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -1,6 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017, 2018 New Vector Ltd +Copyright 2017, 2018, 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. @@ -566,7 +566,7 @@ module.exports = React.createClass({ rows="1" id="textinput" ref="textinput" - className="mx_ChatInviteDialog_input" + className="mx_AddressPickerDialog_input" onChange={this.onQueryChanged} placeholder={this.props.placeholder} defaultValue={this.props.value} @@ -578,7 +578,7 @@ module.exports = React.createClass({ let addressSelector; if (this.state.error) { const validTypeDescriptions = this.props.validAddressTypes.map((t) => _t(addressTypeName[t])); - error =
    + error =
    { _t("You have entered an invalid address.") }
    { _t("Try using one of the following valid address types: %(validTypesList)s.", { @@ -586,9 +586,9 @@ module.exports = React.createClass({ }) }
    ; } else if (this.state.searchError) { - error =
    { this.state.searchError }
    ; + error =
    { this.state.searchError }
    ; } else if (this.state.query.length > 0 && filteredSuggestedList.length === 0 && !this.state.busy) { - error =
    { _t("No results") }
    ; + error =
    { _t("No results") }
    ; } else { addressSelector = ( {this.addressSelector = ref;}} @@ -601,13 +601,13 @@ module.exports = React.createClass({ } return ( - -
    +
    -
    { query }
    +
    { query }
    { error } { addressSelector } { this.props.extraNode } diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index 34370d5b98..ee838b9825 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -1,6 +1,6 @@ /* Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd +Copyright 2018, 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. @@ -55,6 +55,11 @@ export default React.createClass({ // CSS class to apply to dialog div className: PropTypes.string, + // if true, dialog container is 60% of the viewport width. Otherwise, + // the container will have no fixed size, allowing its contents to + // determine its size. Default: true. + fixedWidth: PropTypes.bool, + // Title for the dialog. title: PropTypes.node.isRequired, @@ -72,6 +77,7 @@ export default React.createClass({ getDefaultProps: function() { return { hasCancel: true, + fixedWidth: true, }; }, @@ -113,7 +119,10 @@ export default React.createClass({ return ( { this.props.headerButton } + { cancelButton }
    - { cancelButton } { this.props.children } ); diff --git a/src/components/views/dialogs/BugReportDialog.js b/src/components/views/dialogs/BugReportDialog.js index c874049cc6..4f9b592691 100644 --- a/src/components/views/dialogs/BugReportDialog.js +++ b/src/components/views/dialogs/BugReportDialog.js @@ -108,6 +108,7 @@ export default class BugReportDialog extends React.Component { const Loader = sdk.getComponent("elements.Spinner"); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const Field = sdk.getComponent('elements.Field'); let error = null; if (this.state.err) { @@ -154,36 +155,29 @@ export default class BugReportDialog extends React.Component { }, ) }

    -
    - - -
    -
    - -